80 Commits

Author SHA1 Message Date
David Jennes
4f222ac85d Merge pull request #329 from stencilproject/release/0.15.1
Some checks failed
Publish on Tag / Push To CocoaPods (push) Has been cancelled
Publish on Tag / GitHub Release (push) Has been cancelled
Release 0.15.1
2022-07-31 23:07:25 +02:00
David Jennes
3a98d1ef7d Bump version to 0.15.1 2022-07-31 23:05:06 +02:00
David Jennes
95a24b950f Small docs fix 2022-07-31 23:03:40 +02:00
David Jennes
a3df900bd2 Merge pull request #328 from stencilproject/feature/fix-lazy
Fix `LazyValueWrapper`
2022-07-31 22:58:06 +02:00
David Jennes
59b0c176c7 Changelog entry 2022-07-31 22:54:47 +02:00
David Jennes
bc5051ffe3 Fix implementation of lazy value wrapper 2022-07-31 22:53:24 +02:00
David Jennes
9444ee5c86 Reset changelog 2022-07-30 00:30:18 +02:00
David Jennes
8989f8a189 Merge pull request #327 from stencilproject/release/0.15.0
Some checks failed
Publish on Tag / Push To CocoaPods (push) Has been cancelled
Publish on Tag / GitHub Release (push) Has been cancelled
Release 0.15.0
2022-07-30 00:23:04 +02:00
David Jennes
6bafcffd2b Tweak changelog a bit 2022-07-29 18:32:22 +02:00
David Jennes
c690f3e613 Bump version to 0.15.0 2022-07-29 18:32:22 +02:00
David Jennes
2ddc039129 Rake tweaks 2022-07-29 18:32:22 +02:00
David Jennes
078c7a84e6 Merge pull request #324 from stencilproject/feature/lazy-data
Support lazy context data
2022-07-29 17:53:52 +02:00
David Jennes
5f0c01809d Docs & changelog entry 2022-07-29 03:07:12 +02:00
David Jennes
07d36651bf Tests 2022-07-29 03:07:12 +02:00
David Jennes
1072e919a3 Create mechanism for providing lazily evaluated context data 2022-07-29 03:07:12 +02:00
David Jennes
0d8fdbc3aa Merge pull request #326 from stencilproject/feature/attribution
Add attribution #trivial
2022-07-29 03:05:52 +02:00
David Jennes
5828770138 Bump year 2022-07-29 02:43:18 +02:00
David Jennes
71879ecdc9 Add attribution to all source code 2022-07-29 02:43:12 +02:00
David Jennes
6481534f6c Merge pull request #325 from stencilproject/feature/resolvable-expressions
Resolvable boolean expressions
2022-07-29 02:01:08 +02:00
David Jennes
0fa830c5cb Changelog entry 2022-07-29 01:54:28 +02:00
Ilya Puchka
479fdad30b Update docs 2022-07-29 01:47:57 +02:00
Ilya Puchka
6649b7e716 Parse variables as expressions
removed static boolean expressions
added test for rendering template with boolean expression
2022-07-29 01:47:55 +02:00
David Jennes
242bea54c3 Merge pull request #175 from stencilproject/break-continue
Break, continue and loops' labels
2022-07-29 00:02:10 +02:00
David Jennes
14f4c2a131 Changelog entry 2022-07-28 23:59:31 +02:00
David Jennes
f12d6ed7f3 Update documentation 2022-07-28 23:59:31 +02:00
Ilya Puchka
dd7ea1e097 Access outer loop context via its label 2022-07-28 23:59:31 +02:00
Ilya Puchka
91df84b1a5 Loop labels 2022-07-28 23:59:31 +02:00
Ilya Puchka
a7448b74cf Added break and continue nodes 2022-07-28 23:59:31 +02:00
David Jennes
248d664d4a Merge pull request #182 from stencilproject/blocks-cache
Caching rendered blocks content to reuse them in further calls
2022-07-28 19:07:15 +02:00
Ilya Puchka
41e0c9c9e0 Changelog entry 2022-07-28 19:05:28 +02:00
Ilya Puchka
67f94aa9f0 Update docs 2022-07-28 19:05:28 +02:00
Ilya Puchka
8c379296ca Cache rendered blocks content to reuse them in further calls 2022-07-28 19:01:13 +02:00
David Jennes
4d3f911f5d Merge pull request #287 from stencilproject/trim_whitespace
Add whitespace control mechanisms
2022-07-28 18:25:45 +02:00
David Jennes
b95b18ff60 Try to document this (afaik) 2022-07-28 18:24:10 +02:00
yonaskolb
27a543d748 Changelog entry 2022-07-28 16:43:24 +02:00
yonaskolb
ef97973e85 Implement trim whitespace 2022-07-28 16:43:24 +02:00
David Jennes
d4dc631752 Merge pull request #323 from stencilproject/feature/drop-swift4
Drop Swift 4 support
2022-07-28 16:39:36 +02:00
David Jennes
20b41782a1 Changelog entry 2022-07-28 03:15:54 +02:00
David Jennes
888797b27e Move source files to where SPM expectes them to be 2022-07-28 03:15:53 +02:00
David Jennes
f32c772b99 Warnings-- 2022-07-28 03:13:01 +02:00
David Jennes
e6ee27f64e Drop swift 4.2 support 2022-07-28 03:10:38 +02:00
David Jennes
256388ddc8 Merge pull request #246 from stencilproject/dynamic-member-lookup
Dynamic member lookup (via marker protocol)
2022-07-28 03:07:01 +02:00
David Jennes
099b8414d2 Changelog entry & docs 2022-07-28 03:05:17 +02:00
Ilya Puchka
7247d0a83d Dynamic member lookup (via marker protocol) 2022-07-28 03:05:17 +02:00
David Jennes
203510175f Merge pull request #267 from stencilproject/fix-block-super
Always evaluate `block.super` even if it's not directly used
2022-07-28 02:45:22 +02:00
David Jennes
8e890db688 Changelog entry 2022-07-27 18:53:01 +02:00
Ilya Puchka
701221c0fb always evaluate block.super even if it is not used 2022-07-27 18:51:52 +02:00
David Jennes
a6d0428036 Merge pull request #322 from stencilproject/feature/public-render
Make `render` method public
2022-07-27 05:05:57 +02:00
David Jennes
ee8b4bc4bc Changelog entry 2022-07-27 03:07:32 +02:00
David Jennes
99cc1cac4a Make render with context public 2022-07-27 03:01:37 +02:00
David Jennes
779820ed99 Merge pull request #321 from stencilproject/feature/pr-automation
PR checks automation
2022-07-27 03:01:17 +02:00
David Jennes
828a9b6fc4 Changelog entry 2022-07-27 02:56:21 +02:00
David Jennes
1b72ef27a4 Forgot ruby version 2022-07-27 02:56:21 +02:00
David Jennes
bf6c7ce456 Switch from travis to github actions 2022-07-27 02:56:21 +02:00
David Jennes
0bbb8005bb Fix bunch of warnings 2022-07-27 02:56:21 +02:00
David Jennes
d9a48fbda6 Copy & adapt rake infrastructure from swiftgen projects 2022-07-27 02:56:21 +02:00
David Jennes
d18e27d6e4 Tweak gitignore 2022-07-27 02:09:52 +02:00
David Jennes
ec031f9c7f Merge pull request #292 from stefanomondino/master
Make tokens public
2022-07-26 15:20:17 +02:00
David Jennes
7dbccf9686 Changelog entry 2022-07-26 15:03:55 +02:00
Stefano Mondino
c444fb959d Make tokens public 2022-07-26 15:02:39 +02:00
David Jennes
c7e1c890f8 Reset CHANGELOG 2021-11-03 18:08:03 +01:00
David Jennes
ccd9402682 Merge pull request #319 from stencilproject/release/0.14.2
Release 0.14.2
2021-11-03 18:04:47 +01:00
David Jennes
9f0b9388d2 Bump gems 2021-11-03 17:59:55 +01:00
David Jennes
38f5faec78 Version 0.14.2 2021-11-03 17:59:46 +01:00
David Jennes
a724419474 Merge pull request #314 from astromonkee/update_pathkit_to_1.0.1
Update PathKit & Spectre to support Xcode 13
2021-10-07 23:09:00 +02:00
David Jennes
12b3a2e9bd Add changelog entry 2021-10-07 23:08:06 +02:00
astromonkee
47a44889ae Update Spectre to 0.10.1 2021-09-29 18:43:28 +09:00
astromonkee
01740c61d3 Update pathkit to 1.0.1
Pathkit 1.0.1 adds support for Xcode 13
2021-09-23 20:36:29 +09:00
David Jennes
c729a7d58f Reset CHANGELOG 2021-04-11 16:30:50 +02:00
David Jennes
973e190edf Merge pull request #307 from stencilproject/release/0.14.1
Release 0.14.1
2021-04-11 16:26:15 +02:00
David Jennes
e134aafe7f Version 0.14.1 2021-04-11 00:17:27 +02:00
David Jennes
88fd776a02 Merge pull request #306 from lkuczborski/variable-crash-fix
Fix for crashing range indexes when variable length is 1
2021-04-10 18:08:27 +02:00
Łukasz Kuczborski
8480648bd3 Fixed changelog entry 2021-04-10 05:57:40 +02:00
Łukasz Kuczborski
521a599a60 Fixed logic and tests 2021-04-09 23:37:54 +02:00
Łukasz Kuczborski
371a4737d9 Fixed missing braces 2021-04-09 23:27:00 +02:00
Łukasz Kuczborski
61919c5e8e PR fixes 2021-04-09 23:21:46 +02:00
Łukasz Kuczborski
7c635975d1 Fix for crashing range indexes when variable length is 1 2021-04-09 22:51:55 +02:00
David Jennes
fd107355c2 Merge pull request #305 from danpalmer/patch-1
Fix build warning
2021-02-07 21:09:47 +01:00
Dan Palmer
f5f85d95a9 Fix build warning
`components` is not mutated, so it can be a `let`. This fixes the build warning that otherwise shows up in build logs.
2021-01-18 17:33:13 +00:00
Olivier Halligon
22440c5369 Reset CHANGELOG 2020-08-17 20:45:21 +02:00
88 changed files with 3630 additions and 1356 deletions

7
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,7 @@
version: 2
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"

25
.github/workflows/danger.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: Danger
on:
push:
branches: master
pull_request:
jobs:
check:
name: Danger Check
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
-
name: Run Danger
run: bundle exec danger --verbose --dangerfile=rakelib/Dangerfile
env:
DANGER_GITHUB_API_TOKEN: ${{ secrets.danger_github_api_token }}

23
.github/workflows/lint-cocoapods.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: Lint Cocoapods
on:
push:
branches: master
pull_request:
jobs:
lint:
name: Pod Lint
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
-
name: Lint podspec
run: bundle exec rake pod:lint

View File

@@ -0,0 +1,23 @@
name: Check Versions
on:
push:
branches:
- 'release/**'
jobs:
check_versions:
name: Check Versions
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
-
name: Check versions
run: bundle exec rake release:check_versions

26
.github/workflows/swiftlint.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: SwiftLint
on:
push:
branches: master
pull_request:
jobs:
lint:
name: SwiftLint
runs-on: macos-latest
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
-
name: Lint source code
run: bundle exec rake lint:code
-
name: Lint tests source code
run: bundle exec rake lint:tests

44
.github/workflows/tag-publish.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: Publish on Tag
on:
push:
tags:
- '*'
workflow_dispatch:
jobs:
cocoapods:
name: Push To CocoaPods
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
-
name: Push to CocoaPods
run: bundle exec rake release:cocoapods
env:
COCOAPODS_TRUNK_TOKEN: ${{secrets.COCOAPODS_TRUNK_TOKEN}}
github:
name: GitHub Release
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
-
name: Create release on GitHub
run: bundle exec rake release:github
env:
DANGER_GITHUB_API_TOKEN: ${{ secrets.danger_github_api_token }}

66
.github/workflows/test-spm.yml vendored Normal file
View File

@@ -0,0 +1,66 @@
name: Test SPM
on:
push:
branches: master
pull_request:
jobs:
linux:
name: Test SPM Linux
runs-on: ubuntu-latest
container: swiftgen/swift:5.6
steps:
-
name: Checkout
uses: actions/checkout@v3
-
# Note: we can't use `ruby/setup-ruby` on custom docker images, so we
# have to do our own caching
name: Cache gems
uses: actions/cache@v3
with:
path: vendor/bundle
key: ${{ runner.os }}-gems-${{ hashFiles('Gemfile.lock') }}
restore-keys: |
${{ runner.os }}-gems-
-
name: Cache SPM
uses: actions/cache@v3
with:
path: .build
key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }}
restore-keys: |
${{ runner.os }}-spm-
-
name: Bundle install
run: |
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
-
name: Run tests
run: bundle exec rake spm:test
macos:
name: Test SPM macOS
runs-on: macos-latest
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
-
name: Cache SPM
uses: actions/cache@v3
with:
path: .build
key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }}
restore-keys: |
${{ runner.os }}-spm-
-
name: Run tests
run: bundle exec rake spm:test

77
.gitignore vendored
View File

@@ -1,6 +1,75 @@
.conche/
# Xcode
#
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
## Build generated
build/
DerivedData/
Fixtures/stub-env/**/*.swiftmodule
## Various settings
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata/
## Other
*.moved-aside
*.xcuserstate
*.xcscmblueprint
## Obj-C/Swift specific
*.hmap
*.ipa
*.dSYM.zip
*.dSYM
## Playgrounds
timeline.xctimeline
playground.xcworkspace
# Swift Package Manager
#
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
Packages/
.build/
.swiftpm/
Packages/
Package.pins
*.xcodeproj
# CocoaPods
#
# We recommend against adding the Pods directory to your .gitignore. However
# you should judge for yourself, the pros and cons are mentioned at:
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
#
# Pods/
# Carthage
#
# Add this line if you want to avoid checking in source code from Carthage dependencies.
Carthage/Checkouts
Carthage/Build
# fastlane
#
# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
# screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
# Other stuff
.apitoken
.DS_Store
.idea/
bin/
Frameworks/
Rome/

1
.ruby-version Normal file
View File

@@ -0,0 +1 @@
3.0.4

View File

@@ -1,90 +1,120 @@
swiftlint_version: 0.39.2
disabled_rules:
# Remove this once we remove old swift support
- implicit_return
swiftlint_version: 0.48.0
opt_in_rules:
- accessibility_label_for_image
- anonymous_argument_in_multiline_closure
- anyobject_protocol
- array_init
- attributes
- balanced_xctest_lifecycle
- closure_body_length
- closure_end_indentation
- closure_spacing
- collection_alignment
- comment_spacing
- conditional_returns_on_newline
- contains_over_filter_count
- contains_over_filter_is_empty
- contains_over_first_not_nil
- contains_over_range_nil_comparison
- convenience_type
- discarded_notification_center_observer
- discouraged_assert
- discouraged_none_name
- discouraged_optional_boolean
- discouraged_optional_collection
- duplicate_enum_cases
- duplicate_imports
- empty_collection_literal
- empty_count
- empty_string
- empty_xctest_method
- enum_case_associated_values_count
- fallthrough
- fatal_error_message
- file_header
- first_where
- flatmap_over_map_reduce
- force_unwrapping
- ibinspectable_in_extension
- identical_operands
- inert_defer
- implicit_return
- implicitly_unwrapped_optional
- inclusive_language
- indentation_width
- joined_default_parameter
- last_where
- legacy_hashing
- legacy_multiple
- legacy_objc_type
- legacy_random
- literal_expression_end_indentation
- lower_acl_than_parent
- missing_docs
- modifier_order
- multiline_arguments
- multiline_arguments_brackets
- multiline_function_chains
- multiline_literal_brackets
- multiline_parameters
- multiline_parameters_brackets
- nslocalizedstring_key
- nsobject_prefer_isequal
- nslocalizedstring_require_bundle
- number_separator
- object_literal
- operator_usage_whitespace
- optional_enum_case_matching
- overridden_super_call
- override_in_extension
- prefer_self_in_static_references
- prefer_self_type_over_type_of_self
- prefer_zero_over_explicit_init
- prefixed_toplevel_constant
- private_action
- private_outlet
- private_subject
- prohibited_super_call
- raw_value_for_camel_cased_codable_enum
- reduce_boolean
- reduce_into
- redundant_nil_coalescing
- redundant_objc_attribute
- redundant_type_annotation
- required_enum_case
- return_value_from_void_function
- single_test_class
- sorted_first_last
- sorted_imports
- static_operator
- strong_iboutlet
- switch_case_on_newline
- test_case_accessibility
- toggle_bool
- trailing_closure
- unavailable_function
- unneeded_parentheses_in_closure_argument
- unowned_variable_capture
- unused_capture_list
- unused_control_flow_label
- unused_declaration
- unused_setter_value
- unused_closure_parameter
- vertical_parameter_alignment_on_call
- vertical_whitespace_closing_braces
- vertical_whitespace_opening_braces
- void_function_in_ternary
- weak_delegate
- xct_specific_matcher
- yoda_condition
# Enable this again once we remove old swift support
# - optional_enum_case_matching
# - legacy_multiple
# Rules customization
closure_body_length:
warning: 25
conditional_returns_on_newline:
if_only: true
file_header:
required_pattern: |
\/\/
\/\/ Stencil
\/\/ Copyright © 2022 Stencil
\/\/ MIT Licence
\/\/
indentation_width:
indentation_width: 2
line_length:
warning: 120
error: 200
@@ -92,8 +122,3 @@ line_length:
nesting:
type_level:
warning: 2
# Exclude generated files
excluded:
- .build
- Tests/StencilTests/XCTestManifests.swift

View File

@@ -1,22 +0,0 @@
matrix:
include:
- os: osx
osx_image: xcode11.4
env: SWIFT_VERSION=4.2
- os: osx
osx_image: xcode11.4
env: SWIFT_VERSION=5.0
- os: linux
env: SWIFT_VERSION=4.2
- os: linux
env: SWIFT_VERSION=5.0
language: generic
sudo: required
dist: trusty
install:
- if [ "$TRAVIS_OS_NAME" == "linux" ]; then eval "$(curl -sL https://swiftenv.fuller.li/install.sh)"; fi
- if [ "$TRAVIS_OS_NAME" == "osx" ]; then wget --output-document /tmp/SwiftLint.pkg https://github.com/realm/SwiftLint/releases/download/0.39.2/SwiftLint.pkg &&
sudo installer -pkg /tmp/SwiftLint.pkg -target /; fi
script:
- swift test
- if [ "$TRAVIS_OS_NAME" == "osx" ]; then swiftlint; fi

View File

@@ -1,4 +1,87 @@
# Stencil Changelog
## 0.15.1
### Bug Fixes
- Fix bug in `LazyValueWrapper`, causing it to never resolve.
[David Jennes](https://github.com/djbe)
[#328](https://github.com/stencilproject/Stencil/pull/328)
## 0.15.0
### Breaking
- Drop support for Swift < 5. For Swift 4.2 support, you should use Stencil 0.14.2.
[David Jennes](https://github.com/djbe)
[#323](https://github.com/stencilproject/Stencil/pull/323)
### Enhancements
- Added support for trimming whitespace around blocks with Jinja2 whitespace control symbols. eg `{%- if value +%}`.
[Miguel Bejar](https://github.com/bejar37)
[Yonas Kolb](https://github.com/yonaskolb)
[#92](https://github.com/stencilproject/Stencil/pull/92)
[#287](https://github.com/stencilproject/Stencil/pull/287)
- Added support for adding default whitespace trimming behaviour to an environment.
[Yonas Kolb](https://github.com/yonaskolb)
[#287](https://github.com/stencilproject/Stencil/pull/287)
- Blocks now can be used repeatedly in the template. When block is rendered for the first time its content will be cached and it can be rendered again later using `{{ block.block_name }}`.
[Ilya Puchka](https://github.com/ilyapuchka)
[#158](https://github.com/stencilproject/Stencil/issues/158)
[#182](https://github.com/stencilproject/Stencil/pull/182)
- Added `break` and `continue` tags to break or continue current loop.
[Ilya Puchka](https://github.com/ilyapuchka)
[#175](https://github.com/stencilproject/Stencil/pull/175)
- You can now access outer loop's scope by labeling it: `{% outer: for ... %}... {% for ... %} {{ outer.counter }} {% endfor %}{% endfor %}`.
[Ilya Puchka](https://github.com/ilyapuchka)
[#175](https://github.com/stencilproject/Stencil/pull/175)
- Boolean expressions can now be rendered, i.e `{{ name == "John" }}` will render `true` or `false` depending on the evaluation result.
[Ilya Puchka](https://github.com/ilyapuchka)
[David Jennes](https://github.com/djbe)
[#164](https://github.com/stencilproject/Stencil/pull/164)
[#325](https://github.com/stencilproject/Stencil/pull/325)
- Enable dynamic member lookup using a new `DynamicMemberLookup` marker protocol. Conform your own types to this protocol to support dynamic member from with contexts.
[Ilya Puchka](https://github.com/ilyapuchka)
[#219](https://github.com/stencilproject/Stencil/issues/219)
[#246](https://github.com/stencilproject/Stencil/pull/246)
- Allow providing lazily evaluated context data, using the `LazyValueWrapper` structure.
[David Jennes](https://github.com/djbe)
[#324](https://github.com/stencilproject/Stencil/pull/324)
### Bug Fixes
- Fixed using `{{ block.super }}` inside nodes other than `block`.
[Ilya Puchka](https://github.com/ilyapuchka)
[#266](https://github.com/stencilproject/Stencil/issues/266)
[#267](https://github.com/stencilproject/Stencil/pull/267)
### Internal Changes
- Updated internal maintenance scripts, and switched to GitHub actions.
[David Jennes](https://github.com/djbe)
[#321](https://github.com/stencilproject/Stencil/pull/321)
- Made the `tokens` property on a `Template` public.
[Stefanomondino](https://github.com/stefanomondino)
[#292](https://github.com/stencilproject/Stencil/pull/292)
- Made the `Template.render(_:)` method (that accepts a `Context`) public.
[David Jennes](https://github.com/djbe)
[#322](https://github.com/stencilproject/Stencil/pull/322)
## 0.14.2
### Internal Changes
- Update Spectre (0.10) and PathKit to support Xcode 13.
[Astromonkee](https://github.com/astromonkee)
[#314](https://github.com/stencilproject/Stencil/pull/314)
## 0.14.1
### Bug Fixes
- Fix for crashing range indexes when variable length is 1.
[Łukasz Kuczborski](https://github.com/lkuczborski)
[#306](https://github.com/stencilproject/Stencil/pull/306)
## 0.14.0
@@ -15,10 +98,6 @@
[Ilya Puchka](https://github.com/ilyapuchka)
[#203](https://github.com/stencilproject/Stencil/pull/203)
### Deprecations
_None_
### Bug Fixes
- Fixed using parenthesis in boolean expressions, they now can be used without spaces around them.
@@ -144,7 +223,7 @@ _None_
- The `{% for %}` tag can now iterate over tuples, structures and classes via
their stored properties.
[Ilya Puchka](https://github.com/ilyapuchka)
[#172](https://github.com/stencilproject/Stencil/pull/173)
[#173](https://github.com/stencilproject/Stencil/pull/173)
- Added `split` filter.
[Ilya Puchka](https://github.com/ilyapuchka)
[#187](https://github.com/stencilproject/Stencil/pull/187)
@@ -253,7 +332,7 @@ _None_
### Bug Fixes
- You can now use literal filter arguments which contain quotes.
[#98](https://github.com/kylef/Stencil/pull/98)
[#98](https://github.com/stencilproject/Stencil/pull/98)
## 0.8.0
@@ -397,10 +476,10 @@ _None_
- Variables (`{{ variable.5 }}`) that reference an array index at an unknown
index will now resolve to `nil` instead of causing a crash.
[#72](https://github.com/kylef/Stencil/issues/72)
[#72](https://github.com/stencilproject/Stencil/issues/72)
- Templates can now extend templates that extend other templates.
[#60](https://github.com/kylef/Stencil/issues/60)
[#60](https://github.com/stencilproject/Stencil/issues/60)
- If comparisons will now treat 0 and below numbers as negative.

22
Gemfile
View File

@@ -1,7 +1,21 @@
# frozen_string_literal: true
source "https://rubygems.org"
source 'https://rubygems.org'
gem "octokit"
gem "cocoapods"
gem "rake"
# The bare minimum for building, e.g. in Homebrew
group :build do
gem 'rake', '~> 13.0'
gem 'xcpretty', '~> 0.3'
end
# In addition to :build, for contributing
group :development do
gem 'cocoapods', '~> 1.11'
gem 'danger', '~> 8.4'
gem 'rubocop', '~> 1.22'
end
# For releasing to GitHub
group :release do
gem 'octokit', '~> 4.7'
end

View File

@@ -1,105 +1,189 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.2)
activesupport (4.2.11.3)
i18n (~> 0.7)
minitest (~> 5.1)
thread_safe (~> 0.3, >= 0.3.4)
tzinfo (~> 1.1)
addressable (2.7.0)
CFPropertyList (3.0.5)
rexml
activesupport (6.1.6.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
zeitwerk (~> 2.3)
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
algoliasearch (1.27.3)
algoliasearch (1.27.5)
httpclient (~> 2.8, >= 2.8.3)
json (>= 1.5.1)
ast (2.4.2)
atomos (0.1.3)
claide (1.0.3)
cocoapods (1.9.3)
activesupport (>= 4.0.2, < 5)
claide (1.1.0)
claide-plugins (0.9.2)
cork
nap
open4 (~> 1.3)
cocoapods (1.11.3)
addressable (~> 2.8)
claide (>= 1.0.2, < 2.0)
cocoapods-core (= 1.9.3)
cocoapods-core (= 1.11.3)
cocoapods-deintegrate (>= 1.0.3, < 2.0)
cocoapods-downloader (>= 1.2.2, < 2.0)
cocoapods-downloader (>= 1.4.0, < 2.0)
cocoapods-plugins (>= 1.0.0, < 2.0)
cocoapods-search (>= 1.0.0, < 2.0)
cocoapods-stats (>= 1.0.0, < 2.0)
cocoapods-trunk (>= 1.4.0, < 2.0)
cocoapods-try (>= 1.1.0, < 2.0)
colored2 (~> 3.1)
escape (~> 0.0.4)
fourflusher (>= 2.3.0, < 3.0)
gh_inspector (~> 1.0)
molinillo (~> 0.6.6)
molinillo (~> 0.8.0)
nap (~> 1.0)
ruby-macho (~> 1.4)
xcodeproj (>= 1.14.0, < 2.0)
cocoapods-core (1.9.3)
activesupport (>= 4.0.2, < 6)
ruby-macho (>= 1.0, < 3.0)
xcodeproj (>= 1.21.0, < 2.0)
cocoapods-core (1.11.3)
activesupport (>= 5.0, < 7)
addressable (~> 2.8)
algoliasearch (~> 1.0)
concurrent-ruby (~> 1.1)
fuzzy_match (~> 2.0.4)
nap (~> 1.0)
netrc (~> 0.11)
public_suffix (~> 4.0)
typhoeus (~> 1.0)
cocoapods-deintegrate (1.0.4)
cocoapods-downloader (1.4.0)
cocoapods-deintegrate (1.0.5)
cocoapods-downloader (1.6.3)
cocoapods-plugins (1.0.0)
nap
cocoapods-search (1.0.0)
cocoapods-stats (1.1.0)
cocoapods-trunk (1.5.0)
cocoapods-search (1.0.1)
cocoapods-trunk (1.6.0)
nap (>= 0.8, < 2.0)
netrc (~> 0.11)
cocoapods-try (1.2.0)
colored2 (3.1.2)
concurrent-ruby (1.1.6)
concurrent-ruby (1.1.10)
cork (0.3.0)
colored2 (~> 3.1)
danger (8.6.1)
claide (~> 1.0)
claide-plugins (>= 0.9.2)
colored2 (~> 3.1)
cork (~> 0.1)
faraday (>= 0.9.0, < 2.0)
faraday-http-cache (~> 2.0)
git (~> 1.7)
kramdown (~> 2.3)
kramdown-parser-gfm (~> 1.0)
no_proxy_fix
octokit (~> 4.7)
terminal-table (>= 1, < 4)
escape (0.0.4)
ethon (0.12.0)
ffi (>= 1.3.0)
faraday (1.0.1)
multipart-post (>= 1.2, < 3)
ffi (1.13.1)
ethon (0.15.0)
ffi (>= 1.15.0)
faraday (1.10.0)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-http-cache (2.4.0)
faraday (>= 0.8)
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.1)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
ffi (1.15.5)
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
git (1.11.0)
rchardet (~> 1.8)
httpclient (2.8.3)
i18n (0.9.5)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
json (2.3.1)
minitest (5.14.1)
molinillo (0.6.6)
multipart-post (2.1.1)
json (2.6.2)
kramdown (2.4.0)
rexml
kramdown-parser-gfm (1.1.0)
kramdown (~> 2.0)
minitest (5.16.2)
molinillo (0.8.0)
multipart-post (2.2.3)
nanaimo (0.3.0)
nap (1.1.0)
netrc (0.11.0)
octokit (4.18.0)
faraday (>= 0.9)
sawyer (~> 0.8.0, >= 0.5.3)
public_suffix (4.0.5)
rake (13.0.1)
ruby-macho (1.4.0)
sawyer (0.8.2)
no_proxy_fix (0.1.2)
octokit (4.25.1)
faraday (>= 1, < 3)
sawyer (~> 0.9)
open4 (1.3.4)
parallel (1.22.1)
parser (3.1.2.0)
ast (~> 2.4.1)
public_suffix (4.0.7)
rainbow (3.1.1)
rake (13.0.6)
rchardet (1.8.0)
regexp_parser (2.5.0)
rexml (3.2.5)
rouge (2.0.7)
rubocop (1.32.0)
json (~> 2.3)
parallel (~> 1.10)
parser (>= 3.1.0.0)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.19.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.19.1)
parser (>= 3.1.1.0)
ruby-macho (2.5.1)
ruby-progressbar (1.11.0)
ruby2_keywords (0.0.5)
sawyer (0.9.2)
addressable (>= 2.3.5)
faraday (> 0.8, < 2.0)
thread_safe (0.3.6)
faraday (>= 0.17.3, < 3)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
typhoeus (1.4.0)
ethon (>= 0.9.0)
tzinfo (1.2.7)
thread_safe (~> 0.1)
xcodeproj (1.17.1)
tzinfo (2.0.5)
concurrent-ruby (~> 1.0)
unicode-display_width (2.2.0)
xcodeproj (1.22.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
rexml (~> 3.2.4)
xcpretty (0.3.0)
rouge (~> 2.0.7)
zeitwerk (2.6.0)
PLATFORMS
ruby
DEPENDENCIES
cocoapods
octokit
rake
cocoapods (~> 1.11)
danger (~> 8.4)
octokit (~> 4.7)
rake (~> 13.0)
rubocop (~> 1.22)
xcpretty (~> 0.3)
BUNDLED WITH
2.1.4
2.2.33

View File

@@ -1,4 +1,4 @@
Copyright (c) 2018, Kyle Fuller
Copyright (c) 2022, Kyle Fuller
All rights reserved.
Redistribution and use in source and binary forms, with or without

View File

@@ -6,8 +6,8 @@
"repositoryURL": "https://github.com/kylef/PathKit.git",
"state": {
"branch": null,
"revision": "73f8e9dca9b7a3078cb79128217dc8f2e585a511",
"version": "1.0.0"
"revision": "3bfd2737b700b9a36565a8c94f4ad2b050a5e574",
"version": "1.0.1"
}
},
{
@@ -15,8 +15,8 @@
"repositoryURL": "https://github.com/kylef/Spectre.git",
"state": {
"branch": null,
"revision": "f14ff47f45642aa5703900980b014c2e9394b6e5",
"version": "0.9.0"
"revision": "26cc5e9ae0947092c7139ef7ba612e34646086c7",
"version": "0.10.1"
}
}
]

View File

@@ -1,4 +1,4 @@
// swift-tools-version:4.2
// swift-tools-version:5.0
import PackageDescription
let package = Package(
@@ -7,17 +7,17 @@ let package = Package(
.library(name: "Stencil", targets: ["Stencil"])
],
dependencies: [
.package(url: "https://github.com/kylef/PathKit.git", from: "1.0.0"),
.package(url: "https://github.com/kylef/Spectre.git", from: "0.9.0")
.package(url: "https://github.com/kylef/PathKit.git", from: "1.0.1"),
.package(url: "https://github.com/kylef/Spectre.git", from: "0.10.1")
],
targets: [
.target(name: "Stencil", dependencies: [
"PathKit"
], path: "Sources"),
]),
.testTarget(name: "StencilTests", dependencies: [
"Stencil",
"Spectre"
])
],
swiftLanguageVersions: [.v4_2]
swiftLanguageVersions: [.v5]
)

View File

@@ -1,23 +0,0 @@
// swift-tools-version:5.0
import PackageDescription
let package = Package(
name: "Stencil",
products: [
.library(name: "Stencil", targets: ["Stencil"])
],
dependencies: [
.package(url: "https://github.com/kylef/PathKit.git", from: "1.0.0"),
.package(url: "https://github.com/kylef/Spectre.git", from: "0.9.0")
],
targets: [
.target(name: "Stencil", dependencies: [
"PathKit"
], path: "Sources"),
.testTarget(name: "StencilTests", dependencies: [
"Stencil",
"Spectre"
])
],
swiftLanguageVersions: [.v4_2, .v5]
)

View File

@@ -1,7 +1,5 @@
# Stencil
[![Build Status](https://travis-ci.org/stencilproject/Stencil.svg?branch=master)](https://travis-ci.org/stencilproject/Stencil)
Stencil is a simple and powerful template language for Swift. It provides a
syntax similar to Django and Mustache. If you're familiar with these, you will
feel right at home with Stencil.

View File

@@ -1,10 +1,52 @@
#!/usr/bin/rake
PODSPEC_FILE = 'Stencil.podspec.json'
CHANGELOG_FILE = 'CHANGELOG.md'
if ENV['BUNDLE_GEMFILE'].nil?
puts "\u{274C} Please use bundle exec"
unless defined?(Bundler)
puts 'Please use bundle exec to run the rake command'
exit 1
end
require 'English'
## [ Constants ] ##############################################################
POD_NAME = 'Stencil'
MIN_XCODE_VERSION = 13.0
BUILD_DIR = File.absolute_path('./.build')
## [ Build Tasks ] ############################################################
namespace :files do
desc 'Update all files containing a version'
task :update, [:version] do |_, args|
version = args[:version]
Utils.print_header "Updating files for version #{version}"
podspec = Utils.podspec(POD_NAME)
podspec['version'] = version
podspec['source']['tag'] = version
File.write("#{POD_NAME}.podspec.json", JSON.pretty_generate(podspec) + "\n")
replace('CHANGELOG.md', '## Master' => "\#\# #{version}")
replace("docs/conf.py",
/^version = .*/ => %Q(version = '#{version}'),
/^release = .*/ => %Q(release = '#{version}')
)
docs_package = Utils.first_match_in_file('docs/installation.rst', /\.package\(url: .+ from: "(.+)"/, 1)
replace("docs/installation.rst",
/\.package\(url: .+, from: "(.+)"/ => %Q(.package\(url: "https://github.com/stencilproject/Stencil.git", from: "#{version}"),
/pod 'Stencil', '.*'/ => %Q(pod 'Stencil', '~> #{version}'),
/github "stencilproject\/Stencil" ~> .*/ => %Q(github "stencilproject/Stencil" ~> #{version})
)
end
def replace(file, replacements)
content = File.read(file)
replacements.each do |match, replacement|
content.gsub!(match, replacement)
end
File.write(file, content)
end
end
task :default => 'release:new'

View File

@@ -1,68 +0,0 @@
/// A container for template variables.
public class Context {
var dictionaries: [[String: Any?]]
public let environment: Environment
public init(dictionary: [String: Any] = [:], environment: Environment? = nil) {
if !dictionary.isEmpty {
dictionaries = [dictionary]
} else {
dictionaries = []
}
self.environment = environment ?? Environment()
}
public subscript(key: String) -> Any? {
/// Retrieves a variable's value, starting at the current context and going upwards
get {
for dictionary in Array(dictionaries.reversed()) {
if let value = dictionary[key] {
return value
}
}
return nil
}
/// Set a variable in the current context, deleting the variable if it's nil
set(value) {
if var dictionary = dictionaries.popLast() {
dictionary[key] = value
dictionaries.append(dictionary)
}
}
}
/// Push a new level into the Context
fileprivate func push(_ dictionary: [String: Any] = [:]) {
dictionaries.append(dictionary)
}
/// Pop the last level off of the Context
fileprivate func pop() -> [String: Any?]? {
return dictionaries.popLast()
}
/// Push a new level onto the context for the duration of the execution of the given closure
public func push<Result>(dictionary: [String: Any] = [:], closure: (() throws -> Result)) rethrows -> Result {
push(dictionary)
defer { _ = pop() }
return try closure()
}
public func flatten() -> [String: Any] {
var accumulator: [String: Any] = [:]
for dictionary in dictionaries {
for (key, value) in dictionary {
if let value = value {
accumulator.updateValue(value, forKey: key)
}
}
}
return accumulator
}
}

View File

@@ -0,0 +1,111 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
/// A container for template variables.
public class Context {
var dictionaries: [[String: Any?]]
/// The context's environment, such as registered extensions, classes,
public let environment: Environment
init(dictionaries: [[String: Any?]], environment: Environment) {
self.dictionaries = dictionaries
self.environment = environment
}
/// Create a context from a dictionary (and an env.)
///
/// - Parameters:
/// - dictionary: The context's data
/// - environment: Environment such as extensions,
public convenience init(dictionary: [String: Any] = [:], environment: Environment? = nil) {
self.init(
dictionaries: dictionary.isEmpty ? [] : [dictionary],
environment: environment ?? Environment()
)
}
/// Access variables in this context by name
public subscript(key: String) -> Any? {
/// Retrieves a variable's value, starting at the current context and going upwards
get {
for dictionary in Array(dictionaries.reversed()) {
if let value = dictionary[key] {
return value
}
}
return nil
}
/// Set a variable in the current context, deleting the variable if it's nil
set(value) {
if var dictionary = dictionaries.popLast() {
dictionary[key] = value
dictionaries.append(dictionary)
}
}
}
/// Push a new level into the Context
///
/// - Parameters:
/// - dictionary: The new level data
fileprivate func push(_ dictionary: [String: Any] = [:]) {
dictionaries.append(dictionary)
}
/// Pop the last level off of the Context
///
/// - returns: The popped level
fileprivate func pop() -> [String: Any?]? {
dictionaries.popLast()
}
/// Push a new level onto the context for the duration of the execution of the given closure
///
/// - Parameters:
/// - dictionary: The new level data
/// - closure: The closure to execute
/// - returns: Return value of the closure
public func push<Result>(dictionary: [String: Any] = [:], closure: (() throws -> Result)) rethrows -> Result {
push(dictionary)
defer { _ = pop() }
return try closure()
}
/// Flatten all levels of context data into 1, merging duplicate variables
///
/// - returns: All collected variables
public func flatten() -> [String: Any] {
var accumulator: [String: Any] = [:]
for dictionary in dictionaries {
for (key, value) in dictionary {
if let value = value {
accumulator.updateValue(value, forKey: key)
}
}
}
return accumulator
}
/// Cache result of block by its name in the context top-level, so that it can be later rendered
/// via `{{ block.name }}`
///
/// - Parameters:
/// - name: The name of the stored block
/// - content: The block's rendered content
public func cacheBlock(_ name: String, content: String) {
if var block = dictionaries.first?["block"] as? [String: String] {
block[name] = content
dictionaries[0]["block"] = block
} else {
dictionaries.insert(["block": [name: content]], at: 0)
}
}
}

View File

@@ -0,0 +1,24 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
/// Marker protocol so we can know which types support `@dynamicMemberLookup`. Add this to your own types that support
/// lookup by String.
public protocol DynamicMemberLookup {
/// Get a value for a given `String` key
subscript(dynamicMember member: String) -> Any? { get }
}
public extension DynamicMemberLookup where Self: RawRepresentable {
/// Get a value for a given `String` key
subscript(dynamicMember member: String) -> Any? {
switch member {
case "rawValue":
return rawValue
default:
return nil
}
}
}

View File

@@ -1,18 +1,44 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
/// Container for environment data, such as registered extensions
public struct Environment {
/// The class for loading new templates
public let templateClass: Template.Type
/// List of registered extensions
public var extensions: [Extension]
/// How to handle whitespace
public var trimBehaviour: TrimBehaviour
/// Mechanism for loading new files
public var loader: Loader?
/// Basic initializer
///
/// - Parameters:
/// - loader: Mechanism for loading new files
/// - extensions: List of extension containers
/// - templateClass: Class for newly loaded templates
/// - trimBehaviour: How to handle whitespace
public init(
loader: Loader? = nil,
extensions: [Extension] = [],
templateClass: Template.Type = Template.self
templateClass: Template.Type = Template.self,
trimBehaviour: TrimBehaviour = .nothing
) {
self.templateClass = templateClass
self.loader = loader
self.extensions = extensions + [DefaultExtension()]
self.trimBehaviour = trimBehaviour
}
/// Load a template with the given name
///
/// - Parameters:
/// - name: Name of the template
/// - returns: Loaded template instance
public func loadTemplate(name: String) throws -> Template {
if let loader = loader {
return try loader.loadTemplate(name: name, environment: self)
@@ -21,6 +47,11 @@ public struct Environment {
}
}
/// Load a template with the given names
///
/// - Parameters:
/// - names: Names of the template
/// - returns: Loaded template instance
public func loadTemplate(names: [String]) throws -> Template {
if let loader = loader {
return try loader.loadTemplate(names: names, environment: self)
@@ -29,11 +60,23 @@ public struct Environment {
}
}
/// Render a template with the given name, providing some data
///
/// - Parameters:
/// - name: Name of the template
/// - context: Data for rendering
/// - returns: Rendered output
public func renderTemplate(name: String, context: [String: Any] = [:]) throws -> String {
let template = try loadTemplate(name: name)
return try render(template: template, context: context)
}
/// Render the given template string, providing some data
///
/// - Parameters:
/// - string: Template string
/// - context: Data for rendering
/// - returns: Rendered output
public func renderTemplate(string: String, context: [String: Any] = [:]) throws -> String {
let template = templateClass.init(templateString: string, environment: self)
return try render(template: template, context: context)

View File

@@ -1,3 +1,9 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
public class TemplateDoesNotExist: Error, CustomStringConvertible {
let templateNames: [String]
let loader: Loader?
@@ -20,12 +26,12 @@ public class TemplateDoesNotExist: Error, CustomStringConvertible {
public struct TemplateSyntaxError: Error, Equatable, CustomStringConvertible {
public let reason: String
public var description: String { return reason }
public var description: String { reason }
public internal(set) var token: Token?
public internal(set) var stackTrace: [Token]
public var templateName: String? { return token?.sourceMap.filename }
public var templateName: String? { token?.sourceMap.filename }
var allTokens: [Token] {
return stackTrace + (token.map { [$0] } ?? [])
stackTrace + (token.map { [$0] } ?? [])
}
public init(reason: String, token: Token? = nil, stackTrace: [Token] = []) {

View File

@@ -1,7 +1,19 @@
public protocol Expression: CustomStringConvertible {
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
public protocol Expression: CustomStringConvertible, Resolvable {
func evaluate(context: Context) throws -> Bool
}
extension Expression {
func resolve(_ context: Context) throws -> Any? {
try "\(evaluate(context: context))"
}
}
protocol InfixOperator: Expression {
init(lhs: Expression, rhs: Expression)
}
@@ -18,11 +30,11 @@ final class StaticExpression: Expression, CustomStringConvertible {
}
func evaluate(context: Context) throws -> Bool {
return value
value
}
var description: String {
return "\(value)"
"\(value)"
}
}
@@ -34,11 +46,15 @@ final class VariableExpression: Expression, CustomStringConvertible {
}
var description: String {
return "(variable: \(variable))"
"(variable: \(variable))"
}
func resolve(_ context: Context) throws -> Any? {
try variable.resolve(context)
}
/// Resolves a variable in the given context as boolean
func resolve(context: Context, variable: Resolvable) throws -> Bool {
func evaluate(context: Context) throws -> Bool {
let result = try variable.resolve(context)
var truthy = false
@@ -58,10 +74,6 @@ final class VariableExpression: Expression, CustomStringConvertible {
return truthy
}
func evaluate(context: Context) throws -> Bool {
return try resolve(context: context, variable: variable)
}
}
final class NotExpression: Expression, PrefixOperator, CustomStringConvertible {
@@ -72,11 +84,11 @@ final class NotExpression: Expression, PrefixOperator, CustomStringConvertible {
}
var description: String {
return "not \(expression)"
"not \(expression)"
}
func evaluate(context: Context) throws -> Bool {
return try !expression.evaluate(context: context)
try !expression.evaluate(context: context)
}
}
@@ -90,7 +102,7 @@ final class InExpression: Expression, InfixOperator, CustomStringConvertible {
}
var description: String {
return "(\(lhs) in \(rhs))"
"(\(lhs) in \(rhs))"
}
func evaluate(context: Context) throws -> Bool {
@@ -125,7 +137,7 @@ final class OrExpression: Expression, InfixOperator, CustomStringConvertible {
}
var description: String {
return "(\(lhs) or \(rhs))"
"(\(lhs) or \(rhs))"
}
func evaluate(context: Context) throws -> Bool {
@@ -148,7 +160,7 @@ final class AndExpression: Expression, InfixOperator, CustomStringConvertible {
}
var description: String {
return "(\(lhs) and \(rhs))"
"(\(lhs) and \(rhs))"
}
func evaluate(context: Context) throws -> Bool {
@@ -171,7 +183,7 @@ class EqualityExpression: Expression, InfixOperator, CustomStringConvertible {
}
var description: String {
return "(\(lhs) == \(rhs))"
"(\(lhs) == \(rhs))"
}
func evaluate(context: Context) throws -> Bool {
@@ -206,7 +218,7 @@ class NumericExpression: Expression, InfixOperator, CustomStringConvertible {
}
var description: String {
return "(\(lhs) \(symbol) \(rhs))"
"(\(lhs) \(symbol) \(rhs))"
}
func evaluate(context: Context) throws -> Bool {
@@ -225,61 +237,61 @@ class NumericExpression: Expression, InfixOperator, CustomStringConvertible {
}
var symbol: String {
return ""
""
}
func compare(lhs: Number, rhs: Number) -> Bool {
return false
false
}
}
class MoreThanExpression: NumericExpression {
override var symbol: String {
return ">"
">"
}
override func compare(lhs: Number, rhs: Number) -> Bool {
return lhs > rhs
lhs > rhs
}
}
class MoreThanEqualExpression: NumericExpression {
override var symbol: String {
return ">="
">="
}
override func compare(lhs: Number, rhs: Number) -> Bool {
return lhs >= rhs
lhs >= rhs
}
}
class LessThanExpression: NumericExpression {
override var symbol: String {
return "<"
"<"
}
override func compare(lhs: Number, rhs: Number) -> Bool {
return lhs < rhs
lhs < rhs
}
}
class LessThanEqualExpression: NumericExpression {
override var symbol: String {
return "<="
"<="
}
override func compare(lhs: Number, rhs: Number) -> Bool {
return lhs <= rhs
lhs <= rhs
}
}
class InequalityExpression: EqualityExpression {
override var description: String {
return "(\(lhs) != \(rhs))"
"(\(lhs) != \(rhs))"
}
override func evaluate(context: Context) throws -> Bool {
return try !super.evaluate(context: context)
try !super.evaluate(context: context)
}
}

View File

@@ -1,9 +1,17 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
/// Container for registered tags and filters
open class Extension {
typealias TagParser = (TokenParser, Token) throws -> NodeType
var tags = [String: TagParser]()
var tags = [String: TagParser]()
var filters = [String: Filter]()
/// Simple initializer
public init() {
}
@@ -20,11 +28,11 @@ open class Extension {
}
/// Registers boolean filter with it's negative counterpart
// swiftlint:disable:next discouraged_optional_boolean
public func registerFilter(name: String, negativeFilterName: String, filter: @escaping (Any?) throws -> Bool?) {
// swiftlint:disable:previous discouraged_optional_boolean
filters[name] = .simple(filter)
filters[negativeFilterName] = .simple {
guard let result = try filter($0) else { return nil }
filters[negativeFilterName] = .simple { value in
guard let result = try filter(value) else { return nil }
return !result
}
}
@@ -36,7 +44,7 @@ open class Extension {
/// Registers a template filter with the given name
public func registerFilter(_ name: String, filter: @escaping (Any?, [Any?]) throws -> Any?) {
filters[name] = .arguments({ value, args, _ in try filter(value, args) })
filters[name] = .arguments { value, args, _ in try filter(value, args) }
}
/// Registers a template filter with the given name
@@ -54,6 +62,8 @@ class DefaultExtension: Extension {
fileprivate func registerDefaultTags() {
registerTag("for", parser: ForNode.parse)
registerTag("break", parser: LoopTerminationNode.parse)
registerTag("continue", parser: LoopTerminationNode.parse)
registerTag("if", parser: IfNode.parse)
registerTag("ifnot", parser: IfNode.parse_ifnot)
#if !os(Linux)

View File

@@ -1,3 +1,9 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
class FilterNode: NodeType {
let resolvable: Resolvable
let nodes: [NodeType]

View File

@@ -1,3 +1,9 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
func capitalise(_ value: Any?) -> Any? {
if let array = value as? [Any?] {
return array.map { stringify($0).capitalized }
@@ -74,9 +80,11 @@ func indentFilter(value: Any?, arguments: [Any?]) throws -> Any? {
var indentWidth = 4
if !arguments.isEmpty {
guard let value = arguments[0] as? Int else {
throw TemplateSyntaxError("""
throw TemplateSyntaxError(
"""
'indent' filter width argument must be an Integer (\(String(describing: arguments[0])))
""")
"""
)
}
indentWidth = value
}
@@ -84,9 +92,11 @@ func indentFilter(value: Any?, arguments: [Any?]) throws -> Any? {
var indentationChar = " "
if arguments.count > 1 {
guard let value = arguments[1] as? String else {
throw TemplateSyntaxError("""
throw TemplateSyntaxError(
"""
'indent' filter indentation argument must be a String (\(String(describing: arguments[1]))
""")
"""
)
}
indentationChar = value
}

View File

@@ -1,3 +1,9 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Foundation
class ForNode: NodeType {
@@ -6,17 +12,23 @@ class ForNode: NodeType {
let nodes: [NodeType]
let emptyNodes: [NodeType]
let `where`: Expression?
let label: String?
let token: Token?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
let components = token.components
var components = token.components
var label: String?
if components.first?.hasSuffix(":") == true {
label = String(components.removeFirst().dropLast())
}
func hasToken(_ token: String, at index: Int) -> Bool {
return components.count > (index + 1) && components[index] == token
components.count > (index + 1) && components[index] == token
}
func endsOrHasToken(_ token: String, at index: Int) -> Bool {
return components.count == index || hasToken(token, at: index)
components.count == index || hasToken(token, at: index)
}
guard hasToken("in", at: 2) && endsOrHasToken("where", at: 4) else {
@@ -52,6 +64,7 @@ class ForNode: NodeType {
nodes: forNodes,
emptyNodes: emptyNodes,
where: `where`,
label: label,
token: token
)
}
@@ -62,6 +75,7 @@ class ForNode: NodeType {
nodes: [NodeType],
emptyNodes: [NodeType],
where: Expression? = nil,
label: String? = nil,
token: Token? = nil
) {
self.resolvable = resolvable
@@ -69,6 +83,7 @@ class ForNode: NodeType {
self.nodes = nodes
self.emptyNodes = emptyNodes
self.where = `where`
self.label = label
self.token = token
}
@@ -85,30 +100,53 @@ class ForNode: NodeType {
if !values.isEmpty {
let count = values.count
var result = ""
return try zip(0..., values)
.map { index, item in
let forContext: [String: Any] = [
// collect parent loop contexts
let parentLoopContexts = (context["forloop"] as? [String: Any])?
.filter { ($1 as? [String: Any])?["label"] != nil } ?? [:]
for (index, item) in zip(0..., values) {
var forContext: [String: Any] = [
"first": index == 0,
"last": index == (count - 1),
"counter": index + 1,
"counter0": index,
"length": count
]
if let label = label {
forContext["label"] = label
forContext[label] = forContext
}
forContext.merge(parentLoopContexts) { lhs, _ in lhs }
return try context.push(dictionary: ["forloop": forContext]) {
try push(value: item, context: context) {
var shouldBreak = false
result += try context.push(dictionary: ["forloop": forContext]) {
defer {
// if outer loop should be continued we should break from current loop
if let shouldContinueLabel = context[LoopTerminationNode.continueContextKey] as? String {
shouldBreak = shouldContinueLabel != label || label == nil
} else {
shouldBreak = context[LoopTerminationNode.breakContextKey] != nil
}
}
return try push(value: item, context: context) {
try renderNodes(nodes, context)
}
}
if shouldBreak {
break
}
.joined()
}
return result
} else {
return try context.push {
try renderNodes(emptyNodes, context)
}
}
}
private func push<Result>(value: Any, context: Context, closure: () throws -> (Result)) throws -> Result {
if loopVariables.isEmpty {
@@ -154,9 +192,9 @@ class ForNode: NodeType {
} else if let resolved = resolved {
let mirror = Mirror(reflecting: resolved)
switch mirror.displayStyle {
case .struct?, .tuple?:
case .struct, .tuple:
values = Array(mirror.children)
case .class?:
case .class:
var children = Array(mirror.children)
var currentMirror: Mirror? = mirror
while let superclassMirror = currentMirror?.superclassMirror {
@@ -174,3 +212,69 @@ class ForNode: NodeType {
return values
}
}
struct LoopTerminationNode: NodeType {
static let breakContextKey = "_internal_forloop_break"
static let continueContextKey = "_internal_forloop_continue"
let name: String
let label: String?
let token: Token?
var contextKey: String {
"_internal_forloop_\(name)"
}
private init(name: String, label: String? = nil, token: Token? = nil) {
self.name = name
self.label = label
self.token = token
}
static func parse(_ parser: TokenParser, token: Token) throws -> LoopTerminationNode {
let components = token.components
guard components.count <= 2 else {
throw TemplateSyntaxError("'\(token.contents)' can accept only one parameter")
}
guard parser.hasOpenedForTag() else {
throw TemplateSyntaxError("'\(token.contents)' can be used only inside loop body")
}
return LoopTerminationNode(name: components[0], label: components.count == 2 ? components[1] : nil, token: token)
}
func render(_ context: Context) throws -> String {
let offset = zip(0..., context.dictionaries).reversed().first { _, dictionary in
guard let forContext = dictionary["forloop"] as? [String: Any],
dictionary["forloop"] != nil else { return false }
if let label = label {
return label == forContext["label"] as? String
} else {
return true
}
}?.0
if let offset = offset {
context.dictionaries[offset][contextKey] = label ?? true
} else if let label = label {
throw TemplateSyntaxError("No loop labeled '\(label)' is currently running")
} else {
throw TemplateSyntaxError("No loop is currently running")
}
return ""
}
}
private extension TokenParser {
func hasOpenedForTag() -> Bool {
var openForCount = 0
for parsedToken in parsedTokens.reversed() where parsedToken.kind == .block {
if parsedToken.components.first == "endfor" { openForCount -= 1 }
if parsedToken.components.first == "for" { openForCount += 1 }
}
return openForCount > 0
}
}

View File

@@ -1,3 +1,9 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
enum Operator {
case infix(String, Int, InfixOperator.Type)
case prefix(String, Int, PrefixOperator.Type)
@@ -10,9 +16,8 @@ enum Operator {
return name
}
}
}
let operators: [Operator] = [
static let all: [Operator] = [
.infix("in", 5, InExpression.self),
.infix("or", 6, OrExpression.self),
.infix("and", 7, AndExpression.self),
@@ -23,10 +28,11 @@ let operators: [Operator] = [
.infix(">=", 10, MoreThanEqualExpression.self),
.infix("<", 10, LessThanExpression.self),
.infix("<=", 10, LessThanEqualExpression.self)
]
]
}
func findOperator(name: String) -> Operator? {
for `operator` in operators where `operator`.name == name {
for `operator` in Operator.all where `operator`.name == name {
return `operator`
}
@@ -106,7 +112,7 @@ final class IfExpressionParser {
}
static func parser(components: [String], environment: Environment, token: Token) throws -> IfExpressionParser {
return try IfExpressionParser(components: ArraySlice(components), environment: environment, token: token)
try IfExpressionParser(components: ArraySlice(components), environment: environment, token: token)
}
private init(components: ArraySlice<String>, environment: Environment, token: Token) throws {
@@ -117,7 +123,7 @@ final class IfExpressionParser {
if component == "(" {
bracketsBalance += 1
let (expression, parsedCount) = try IfExpressionParser.subExpression(
let (expression, parsedCount) = try Self.subExpression(
from: components.suffix(from: index + 1),
environment: environment,
token: token
@@ -152,10 +158,10 @@ final class IfExpressionParser {
token: Token
) throws -> (Expression, Int) {
var bracketsBalance = 1
let subComponents = components.prefix {
if $0 == "(" {
let subComponents = components.prefix { component in
if component == "(" {
bracketsBalance += 1
} else if $0 == ")" {
} else if component == ")" {
bracketsBalance -= 1
}
return bracketsBalance != 0
@@ -220,7 +226,7 @@ final class IfCondition {
}
func render(_ context: Context) throws -> String {
return try context.push {
try context.push {
try renderNodes(nodes, context)
}
}

View File

@@ -1,3 +1,9 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import PathKit
class IncludeNode: NodeType {
@@ -9,11 +15,13 @@ class IncludeNode: NodeType {
let bits = token.components
guard bits.count == 2 || bits.count == 3 else {
throw TemplateSyntaxError("""
throw TemplateSyntaxError(
"""
'include' tag requires one argument, the template file to be included. \
A second optional argument can be used to specify the context that will \
be passed to the included file
""")
"""
)
}
return IncludeNode(templateName: Variable(bits[1]), includeContext: bits.count == 3 ? bits[2] : nil, token: token)

View File

@@ -1,5 +1,11 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
class BlockContext {
class var contextKey: String { return "block_context" }
class var contextKey: String { "block_context" }
// contains mapping of block names to their nodes and templates where they are defined
var blocks: [String: [BlockNode]]
@@ -35,11 +41,9 @@ class BlockContext {
extension Collection {
func any(_ closure: (Iterator.Element) -> Bool) -> Iterator.Element? {
for element in self {
if closure(element) {
for element in self where closure(element) {
return element
}
}
return nil
}
@@ -138,7 +142,11 @@ class BlockNode: NodeType {
func render(_ context: Context) throws -> String {
if let blockContext = context[BlockContext.contextKey] as? BlockContext, let child = blockContext.pop(name) {
let childContext = try self.childContext(child, blockContext: blockContext, context: context)
let childContext: [String: Any] = [
BlockContext.contextKey: blockContext,
"block": ["super": try self.render(context)]
]
// render extension node
do {
return try context.push(dictionary: childContext) {
@@ -149,37 +157,8 @@ class BlockNode: NodeType {
}
}
return try renderNodes(nodes, context)
}
// child node is a block node from child template that extends this node (has the same name)
func childContext(_ child: BlockNode, blockContext: BlockContext, context: Context) throws -> [String: Any] {
var childContext: [String: Any] = [BlockContext.contextKey: blockContext]
if let blockSuperNode = child.nodes.first(where: {
if let token = $0.token, case .variable = token.kind, token.contents == "block.super" {
return true
} else {
return false
}
}) {
do {
// render base node so that its content can be used as part of child node that extends it
childContext["block"] = ["super": try self.render(context)]
} catch {
if let error = error as? TemplateSyntaxError {
throw TemplateSyntaxError(
reason: error.reason,
token: blockSuperNode.token,
stackTrace: error.allTokens)
} else {
throw TemplateSyntaxError(
reason: "\(error)",
token: blockSuperNode.token,
stackTrace: [])
}
}
}
return childContext
let result = try renderNodes(nodes, context)
context.cacheBlock(name, content: result)
return result
}
}

View File

@@ -1,3 +1,9 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Foundation
/// A structure used to represent a template variable, and to resolve it in a given context.

View File

@@ -0,0 +1,63 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
/// Used to lazily set context data. Useful for example if you have some data that requires heavy calculations, and may
/// not be used in every render possiblity.
public final class LazyValueWrapper {
private let closure: (Context) throws -> Any
private let context: Context?
private var cachedValue: Any?
/// Create a wrapper that'll use a **reference** to the current context.
/// This means when the closure is evaluated, it'll use the **active** context at that moment.
///
/// - Parameters:
/// - closure: The closure to lazily evaluate
public init(closure: @escaping (Context) throws -> Any) {
self.context = nil
self.closure = closure
}
/// Create a wrapper that'll create a **copy** of the current context.
/// This means when the closure is evaluated, it'll use the context **as it was** when this wrapper was created.
///
/// - Parameters:
/// - context: The context to use during evaluation
/// - closure: The closure to lazily evaluate
/// - Note: This will use more memory than the other `init` as it needs to keep a copy of the full context around.
public init(copying context: Context, closure: @escaping (Context) throws -> Any) {
self.context = Context(dictionaries: context.dictionaries, environment: context.environment)
self.closure = closure
}
/// Shortcut for creating a lazy wrapper when you don't need access to the Stencil context.
///
/// - Parameters:
/// - closure: The closure to lazily evaluate
public init(_ closure: @autoclosure @escaping () throws -> Any) {
self.context = nil
self.closure = { _ in try closure() }
}
}
extension LazyValueWrapper {
func value(context: Context) throws -> Any {
if let value = cachedValue {
return value
} else {
let value = try closure(self.context ?? context)
cachedValue = value
return value
}
}
}
extension LazyValueWrapper: Resolvable {
public func resolve(_ context: Context) throws -> Any? {
let value = try self.value(context: context)
return try (value as? Resolvable)?.resolve(context) ?? value
}
}

View File

@@ -1,3 +1,9 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Foundation
typealias Line = (content: String, number: UInt, range: Range<String.Index>)
@@ -11,6 +17,9 @@ struct Lexer {
/// `{` character, for example `{{`, `{%`, `{#`, ...
private static let tokenChars: [Unicode.Scalar] = ["{", "%", "#"]
/// The minimum length of a tag
private static let tagLength = 2
/// The token end characters, corresponding to their token start characters.
/// For example, a variable token starts with `{{` and ends with `}}`
private static let tokenCharMap: [Unicode.Scalar: Unicode.Scalar] = [
@@ -19,17 +28,33 @@ struct Lexer {
"#": "#"
]
/// Characters controlling whitespace trimming behaviour
private static let behaviourMap: [Character: WhitespaceBehaviour.Behaviour] = [
"+": .keep,
"-": .trim
]
init(templateName: String? = nil, templateString: String) {
self.templateName = templateName
self.templateString = templateString
self.lines = templateString.components(separatedBy: .newlines).enumerated().compactMap {
guard !$0.element.isEmpty,
let range = templateString.range(of: $0.element) else { return nil }
return (content: $0.element, number: UInt($0.offset + 1), range)
self.lines = zip(1..., templateString.components(separatedBy: .newlines)).compactMap { index, line in
guard !line.isEmpty,
let range = templateString.range(of: line) else { return nil }
return (content: line, number: UInt(index), range)
}
}
private func behaviour(string: String, tagLength: Int) -> WhitespaceBehaviour {
let leftIndex = string.index(string.startIndex, offsetBy: tagLength, limitedBy: string.endIndex)
let rightIndex = string.index(string.endIndex, offsetBy: -(tagLength + 1), limitedBy: string.startIndex)
return WhitespaceBehaviour(
leading: Self.behaviourMap[leftIndex.map { string[$0] } ?? " "] ?? .unspecified,
trailing: Self.behaviourMap[rightIndex.map { string[$0] } ?? " "] ?? .unspecified
)
}
/// Create a token that will be passed on to the parser, with the given
/// content and a range. The content will be tested to see if it's a
/// `variable`, a `block` or a `comment`, otherwise it'll default to a simple
@@ -40,9 +65,9 @@ struct Lexer {
/// - range: The range within the template content, used for smart
/// error reporting
func createToken(string: String, at range: Range<String.Index>) -> Token {
func strip() -> String {
guard string.count > 4 else { return "" }
let trimmed = String(string.dropFirst(2).dropLast(2))
func strip(length: (Int, Int) = (Self.tagLength, Self.tagLength)) -> String {
guard string.count > (length.0 + length.1) else { return "" }
let trimmed = String(string.dropFirst(length.0).dropLast(length.1))
.components(separatedBy: "\n")
.filter { !$0.isEmpty }
.map { $0.trim(character: " ") }
@@ -51,7 +76,13 @@ struct Lexer {
}
if string.hasPrefix("{{") || string.hasPrefix("{%") || string.hasPrefix("{#") {
let value = strip()
let behaviour = string.hasPrefix("{%") ? behaviour(string: string, tagLength: Self.tagLength) : .unspecified
let stripLengths = (
Self.tagLength + (behaviour.leading != .unspecified ? 1 : 0),
Self.tagLength + (behaviour.trailing != .unspecified ? 1 : 0)
)
let value = strip(length: stripLengths)
let range = templateString.range(of: value, range: range) ?? range
let location = rangeLocation(range)
let sourceMap = SourceMap(filename: templateName, location: location)
@@ -59,7 +90,7 @@ struct Lexer {
if string.hasPrefix("{{") {
return .variable(value: value, at: sourceMap)
} else if string.hasPrefix("{%") {
return .block(value: value, at: sourceMap)
return .block(value: strip(length: stripLengths), at: sourceMap, whitespace: behaviour)
} else if string.hasPrefix("{#") {
return .comment(value: value, at: sourceMap)
}
@@ -79,12 +110,12 @@ struct Lexer {
let scanner = Scanner(templateString)
while !scanner.isEmpty {
if let (char, text) = scanner.scanForTokenStart(Lexer.tokenChars) {
if let (char, text) = scanner.scanForTokenStart(Self.tokenChars) {
if !text.isEmpty {
tokens.append(createToken(string: text, at: scanner.range))
}
guard let end = Lexer.tokenCharMap[char] else { continue }
guard let end = Self.tokenCharMap[char] else { continue }
let result = scanner.scanForTokenEnd(end)
tokens.append(createToken(string: result, at: scanner.range))
} else {
@@ -127,7 +158,7 @@ class Scanner {
}
var isEmpty: Bool {
return content.isEmpty
content.isEmpty
}
/// Scans for the end of a token, with a specific ending character. If we're
@@ -144,8 +175,8 @@ class Scanner {
func scanForTokenEnd(_ tokenChar: Unicode.Scalar) -> String {
var foundChar = false
for (index, char) in content.unicodeScalars.enumerated() {
if foundChar && char == Scanner.tokenEndDelimiter {
for (index, char) in zip(0..., content.unicodeScalars) {
if foundChar && char == Self.tokenEndDelimiter {
let result = String(content.unicodeScalars.prefix(index + 1))
content = String(content.unicodeScalars.dropFirst(index + 1))
range = range.upperBound..<originalContent.unicodeScalars.index(range.upperBound, offsetBy: index + 1)
@@ -178,14 +209,14 @@ class Scanner {
var foundBrace = false
range = range.upperBound..<range.upperBound
for (index, char) in content.unicodeScalars.enumerated() {
for (index, char) in zip(0..., content.unicodeScalars) {
if foundBrace && tokenChars.contains(char) {
let result = String(content.unicodeScalars.prefix(index - 1))
content = String(content.unicodeScalars.dropFirst(index - 1))
range = range.upperBound..<originalContent.unicodeScalars.index(range.upperBound, offsetBy: index - 1)
return (char, result)
} else {
foundBrace = (char == Scanner.tokenStartDelimiter)
foundBrace = (char == Self.tokenStartDelimiter)
}
}
@@ -227,4 +258,5 @@ extension String {
}
}
/// Location in some content (text)
public typealias ContentLocation = (content: String, lineNumber: UInt, lineOffset: Int)

View File

@@ -1,12 +1,22 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Foundation
import PathKit
/// Type used for loading a template
public protocol Loader {
/// Load a template with the given name
func loadTemplate(name: String, environment: Environment) throws -> Template
/// Load a template with the given list of names
func loadTemplate(names: [String], environment: Environment) throws -> Template
}
extension Loader {
/// Default implementation, tries to load the first template that exists from the list of given names
public func loadTemplate(names: [String], environment: Environment) throws -> Template {
for name in names {
do {
@@ -31,13 +41,13 @@ public class FileSystemLoader: Loader, CustomStringConvertible {
}
public init(bundle: [Bundle]) {
self.paths = bundle.map {
Path($0.bundlePath)
self.paths = bundle.map { bundle in
Path(bundle.bundlePath)
}
}
public var description: String {
return "FileSystemLoader(\(paths))"
"FileSystemLoader(\(paths))"
}
public func loadTemplate(name: String, environment: Environment) throws -> Template {
@@ -119,6 +129,6 @@ class SuspiciousFileOperation: Error {
}
var description: String {
return "Path `\(path)` is located outside of base path `\(basePath)`"
"Path `\(path)` is located outside of base path `\(basePath)`"
}
}

View File

@@ -1,5 +1,12 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Foundation
/// Represents a parsed node
public protocol NodeType {
/// Render the node in the given context
func render(_ context: Context) throws -> String
@@ -10,17 +17,27 @@ public protocol NodeType {
/// Render the collection of nodes in the given context
public func renderNodes(_ nodes: [NodeType], _ context: Context) throws -> String {
return try nodes
.map {
var result = ""
for node in nodes {
do {
return try $0.render(context)
result += try node.render(context)
} catch {
throw error.withToken($0.token)
throw error.withToken(node.token)
}
let shouldBreak = context[LoopTerminationNode.breakContextKey] != nil
let shouldContinue = context[LoopTerminationNode.continueContextKey] != nil
if shouldBreak || shouldContinue {
break
}
}
.joined()
return result
}
/// Simple node, used for triggering a closure during rendering
public class SimpleNode: NodeType {
public let handler: (Context) throws -> String
public let token: Token?
@@ -31,28 +48,45 @@ public class SimpleNode: NodeType {
}
public func render(_ context: Context) throws -> String {
return try handler(context)
try handler(context)
}
}
/// Represents a block of text, renders the text
public class TextNode: NodeType {
public let text: String
public let token: Token?
public let trimBehaviour: TrimBehaviour
public init(text: String) {
public init(text: String, trimBehaviour: TrimBehaviour = .nothing) {
self.text = text
self.token = nil
self.trimBehaviour = trimBehaviour
}
public func render(_ context: Context) throws -> String {
return self.text
var string = self.text
if trimBehaviour.leading != .nothing, !string.isEmpty {
let range = NSRange(..<string.endIndex, in: string)
string = TrimBehaviour.leadingRegex(trim: trimBehaviour.leading)
.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: "")
}
if trimBehaviour.trailing != .nothing, !string.isEmpty {
let range = NSRange(..<string.endIndex, in: string)
string = TrimBehaviour.trailingRegex(trim: trimBehaviour.trailing)
.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: "")
}
return string
}
}
/// Representing something that can be resolved in a context
public protocol Resolvable {
/// Try to resolve this with the given context
func resolve(_ context: Context) throws -> Any?
}
/// Represents a variable, renders the variable, may have conditional expressions.
public class VariableNode: NodeType {
public let variable: Resolvable
public var token: Token?
@@ -60,35 +94,41 @@ public class VariableNode: NodeType {
let elseExpression: Resolvable?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
var components = token.components
let components = token.components
func hasToken(_ token: String, at index: Int) -> Bool {
return components.count > (index + 1) && components[index] == token
components.count > (index + 1) && components[index] == token
}
func compileResolvable(_ components: [String], containedIn token: Token) throws -> Resolvable {
try (try? parser.compileExpression(components: components, token: token)) ??
parser.compileFilter(components.joined(separator: " "), containedIn: token)
}
let variable: Resolvable
let condition: Expression?
let elseExpression: Resolvable?
if hasToken("if", at: 1) {
variable = try compileResolvable([components[0]], containedIn: token)
let components = components.suffix(from: 2)
if let elseIndex = components.firstIndex(of: "else") {
condition = try parser.compileExpression(components: Array(components.prefix(upTo: elseIndex)), token: token)
let elseToken = components.suffix(from: elseIndex.advanced(by: 1)).joined(separator: " ")
elseExpression = try parser.compileResolvable(elseToken, containedIn: token)
let elseToken = Array(components.suffix(from: elseIndex.advanced(by: 1)))
elseExpression = try compileResolvable(elseToken, containedIn: token)
} else {
condition = try parser.compileExpression(components: Array(components), token: token)
elseExpression = nil
}
} else {
} else if !components.isEmpty {
variable = try compileResolvable(components, containedIn: token)
condition = nil
elseExpression = nil
}
guard let resolvable = components.first else {
} else {
throw TemplateSyntaxError(reason: "Missing variable name", token: token)
}
let filter = try parser.compileResolvable(resolvable, containedIn: token)
return VariableNode(variable: filter, token: token, condition: condition, elseExpression: elseExpression)
return VariableNode(variable: variable, token: token, condition: condition, elseExpression: elseExpression)
}
public init(variable: Resolvable, token: Token? = nil) {
@@ -137,7 +177,7 @@ func stringify(_ result: Any?) -> String {
}
func unwrap(_ array: [Any?]) -> [Any] {
return array.map { (item: Any?) -> Any in
array.map { (item: Any?) -> Any in
if let item = item {
if let items = item as? [Any?] {
return unwrap(items)

View File

@@ -1,3 +1,9 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
#if !os(Linux)
import Foundation

View File

@@ -1,5 +1,13 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
/// Creates a checker that will stop parsing if it encounters a list of tags.
/// Useful for example for scanning until a given "end"-node.
public func until(_ tags: [String]) -> ((TokenParser, Token) -> Bool) {
return { parser, token in
{ _, token in
if let name = token.components.first {
for tag in tags where name == tag {
return true
@@ -12,11 +20,15 @@ public func until(_ tags: [String]) -> ((TokenParser, Token) -> Bool) {
/// A class for parsing an array of tokens and converts them into a collection of Node's
public class TokenParser {
/// Parser for finding a kind of node
public typealias TagParser = (TokenParser, Token) throws -> NodeType
fileprivate var tokens: [Token]
fileprivate(set) var parsedTokens: [Token] = []
fileprivate let environment: Environment
fileprivate var previousWhiteSpace: WhitespaceBehaviour.Behaviour?
/// Simple initializer
public init(tokens: [Token], environment: Environment) {
self.tokens = tokens
self.environment = environment
@@ -24,9 +36,11 @@ public class TokenParser {
/// Parse the given tokens into nodes
public func parse() throws -> [NodeType] {
return try parse(nil)
try parse(nil)
}
/// Parse nodes until a specific "something" is detected, determined by the provided closure.
/// Combine this with the `until(:)` function above to scan nodes until a given token.
public func parse(_ parseUntil: ((_ parser: TokenParser, _ token: Token) -> (Bool))?) throws -> [NodeType] {
var nodes = [NodeType]()
@@ -35,17 +49,24 @@ public class TokenParser {
switch token.kind {
case .text:
nodes.append(TextNode(text: token.contents))
nodes.append(TextNode(text: token.contents, trimBehaviour: trimBehaviour))
case .variable:
previousWhiteSpace = nil
try nodes.append(VariableNode.parse(self, token: token))
case .block:
previousWhiteSpace = token.whitespace?.trailing
if let parseUntil = parseUntil, parseUntil(self, token) {
prependToken(token)
return nodes
}
if let tag = token.components.first {
if var tag = token.components.first {
do {
// special case for labeled tags (such as for loops)
if tag.hasSuffix(":") && token.components.count >= 2 {
tag = token.components[1]
}
let parser = try environment.findTag(name: tag)
let node = try parser(self, token)
nodes.append(node)
@@ -54,6 +75,7 @@ public class TokenParser {
}
}
case .comment:
previousWhiteSpace = nil
continue
}
}
@@ -61,31 +83,63 @@ public class TokenParser {
return nodes
}
/// Pop the next token (returning it)
public func nextToken() -> Token? {
if !tokens.isEmpty {
return tokens.remove(at: 0)
let nextToken = tokens.remove(at: 0)
parsedTokens.append(nextToken)
return nextToken
}
return nil
}
func peekWhitespace() -> WhitespaceBehaviour.Behaviour? {
tokens.first?.whitespace?.leading
}
/// Insert a token
public func prependToken(_ token: Token) {
tokens.insert(token, at: 0)
if parsedTokens.last == token {
parsedTokens.removeLast()
}
}
/// Create filter expression from a string contained in provided token
public func compileFilter(_ filterToken: String, containedIn token: Token) throws -> Resolvable {
return try environment.compileFilter(filterToken, containedIn: token)
try environment.compileFilter(filterToken, containedIn: token)
}
/// Create boolean expression from components contained in provided token
public func compileExpression(components: [String], token: Token) throws -> Expression {
return try environment.compileExpression(components: components, containedIn: token)
try environment.compileExpression(components: components, containedIn: token)
}
/// Create resolvable (i.e. range variable or filter expression) from a string contained in provided token
public func compileResolvable(_ token: String, containedIn containingToken: Token) throws -> Resolvable {
return try environment.compileResolvable(token, containedIn: containingToken)
try environment.compileResolvable(token, containedIn: containingToken)
}
private var trimBehaviour: TrimBehaviour {
var behaviour: TrimBehaviour = .nothing
if let leading = previousWhiteSpace {
if leading == .unspecified {
behaviour.leading = environment.trimBehaviour.trailing
} else {
behaviour.leading = leading == .trim ? .whitespaceAndNewLines : .nothing
}
}
if let trailing = peekWhitespace() {
if trailing == .unspecified {
behaviour.trailing = environment.trimBehaviour.leading
} else {
behaviour.trailing = trailing == .trim ? .whitespaceAndNewLines : .nothing
}
}
return behaviour
}
}
@@ -111,10 +165,12 @@ extension Environment {
if suggestedFilters.isEmpty {
throw TemplateSyntaxError("Unknown filter '\(name)'.")
} else {
throw TemplateSyntaxError("""
throw TemplateSyntaxError(
"""
Unknown filter '\(name)'. \
Found similar filters: \(suggestedFilters.map { "'\($0)'" }.joined(separator: ", ")).
""")
"""
)
}
}
@@ -134,7 +190,7 @@ extension Environment {
/// Create filter expression from a string
public func compileFilter(_ token: String) throws -> Resolvable {
return try FilterExpression(token: token, environment: self)
try FilterExpression(token: token, environment: self)
}
/// Create filter expression from a string contained in provided token
@@ -165,26 +221,26 @@ extension Environment {
/// Create resolvable (i.e. range variable or filter expression) from a string
public func compileResolvable(_ token: String) throws -> Resolvable {
return try RangeVariable(token, environment: self)
try RangeVariable(token, environment: self)
?? compileFilter(token)
}
/// Create resolvable (i.e. range variable or filter expression) from a string contained in provided token
public func compileResolvable(_ token: String, containedIn containingToken: Token) throws -> Resolvable {
return try RangeVariable(token, environment: self, containedIn: containingToken)
try RangeVariable(token, environment: self, containedIn: containingToken)
?? compileFilter(token, containedIn: containingToken)
}
/// Create boolean expression from components contained in provided token
public func compileExpression(components: [String], containedIn token: Token) throws -> Expression {
return try IfExpressionParser.parser(components: components, environment: self, token: token).parse()
try IfExpressionParser.parser(components: components, environment: self, token: token).parse()
}
}
// https://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows
extension String {
subscript(_ index: Int) -> Character {
return self[self.index(self.startIndex, offsetBy: index)]
self[self.index(self.startIndex, offsetBy: index)]
}
func levenshteinDistance(_ target: String) -> Int {

View File

@@ -1,7 +1,14 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Foundation
import PathKit
#if os(Linux)
// swiftlint:disable:next prefixed_toplevel_constant
let NSFileNoSuchFileError = 4
#endif
@@ -9,7 +16,9 @@ let NSFileNoSuchFileError = 4
open class Template: ExpressibleByStringLiteral {
let templateString: String
var environment: Environment
let tokens: [Token]
/// The list of parsed (lexed) tokens
public let tokens: [Token]
/// The name of the loaded Template if the Template was loaded from a Loader
public let name: String?
@@ -65,7 +74,7 @@ open class Template: ExpressibleByStringLiteral {
}
/// Render the given template with a context
func render(_ context: Context) throws -> String {
public func render(_ context: Context) throws -> String {
let context = context
let parser = TokenParser(tokens: tokens, environment: context.environment)
let nodes = try parser.parse()
@@ -75,6 +84,6 @@ open class Template: ExpressibleByStringLiteral {
// swiftlint:disable discouraged_optional_collection
/// Render the given template
open func render(_ dictionary: [String: Any]? = nil) throws -> String {
return try render(Context(dictionary: dictionary ?? [:], environment: environment))
try render(Context(dictionary: dictionary ?? [:], environment: environment))
}
}

View File

@@ -1,3 +1,9 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Foundation
extension String {
@@ -19,7 +25,7 @@ extension String {
if character == separate {
if separate != separator {
word.append(separate)
} else if (singleQuoteCount % 2 == 0 || doubleQuoteCount % 2 == 0) && !word.isEmpty {
} else if (singleQuoteCount.isMultiple(of: 2) || doubleQuoteCount.isMultiple(of: 2)) && !word.isEmpty {
appendWord(word, to: &components)
word = ""
}
@@ -45,7 +51,12 @@ extension String {
if !components.isEmpty {
if let precedingChar = components.last?.last, specialCharacters.contains(precedingChar) {
// special case for labeled for-loops
if components.count == 1 && word == "for" {
components.append(word)
} else {
components[components.count - 1] += word
}
} else if specialCharacters.contains(word) {
components[components.count - 1] += word
} else if word != "(" && word.first == "(" || word != ")" && word.first == ")" {
@@ -75,10 +86,23 @@ public struct SourceMap: Equatable {
static let unknown = SourceMap()
public static func == (lhs: SourceMap, rhs: SourceMap) -> Bool {
return lhs.filename == rhs.filename && lhs.location == rhs.location
lhs.filename == rhs.filename && lhs.location == rhs.location
}
}
public struct WhitespaceBehaviour: Equatable {
public enum Behaviour {
case unspecified
case trim
case keep
}
let leading: Behaviour
let trailing: Behaviour
public static let unspecified = WhitespaceBehaviour(leading: .unspecified, trailing: .unspecified)
}
public class Token: Equatable {
public enum Kind: Equatable {
/// A token representing a piece of text.
@@ -94,37 +118,43 @@ public class Token: Equatable {
public let contents: String
public let kind: Kind
public let sourceMap: SourceMap
public var whitespace: WhitespaceBehaviour?
/// Returns the underlying value as an array seperated by spaces
public private(set) lazy var components: [String] = self.contents.smartSplit()
init(contents: String, kind: Kind, sourceMap: SourceMap) {
init(contents: String, kind: Kind, sourceMap: SourceMap, whitespace: WhitespaceBehaviour? = nil) {
self.contents = contents
self.kind = kind
self.sourceMap = sourceMap
self.whitespace = whitespace
}
/// A token representing a piece of text.
public static func text(value: String, at sourceMap: SourceMap) -> Token {
return Token(contents: value, kind: .text, sourceMap: sourceMap)
Token(contents: value, kind: .text, sourceMap: sourceMap)
}
/// A token representing a variable.
public static func variable(value: String, at sourceMap: SourceMap) -> Token {
return Token(contents: value, kind: .variable, sourceMap: sourceMap)
Token(contents: value, kind: .variable, sourceMap: sourceMap)
}
/// A token representing a comment.
public static func comment(value: String, at sourceMap: SourceMap) -> Token {
return Token(contents: value, kind: .comment, sourceMap: sourceMap)
Token(contents: value, kind: .comment, sourceMap: sourceMap)
}
/// A token representing a template block.
public static func block(value: String, at sourceMap: SourceMap) -> Token {
return Token(contents: value, kind: .block, sourceMap: sourceMap)
public static func block(
value: String,
at sourceMap: SourceMap,
whitespace: WhitespaceBehaviour = .unspecified
) -> Token {
Token(contents: value, kind: .block, sourceMap: sourceMap, whitespace: whitespace)
}
public static func == (lhs: Token, rhs: Token) -> Bool {
return lhs.contents == rhs.contents && lhs.kind == rhs.kind && lhs.sourceMap == rhs.sourceMap
lhs.contents == rhs.contents && lhs.kind == rhs.kind && lhs.sourceMap == rhs.sourceMap
}
}

View File

@@ -0,0 +1,76 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Foundation
public struct TrimBehaviour: Equatable {
var leading: Trim
var trailing: Trim
public enum Trim {
/// nothing
case nothing
/// tabs and spaces
case whitespace
/// tabs and spaces and a single new line
case whitespaceAndOneNewLine
/// all tabs spaces and newlines
case whitespaceAndNewLines
}
public init(leading: Trim, trailing: Trim) {
self.leading = leading
self.trailing = trailing
}
/// doesn't touch newlines
public static let nothing = TrimBehaviour(leading: .nothing, trailing: .nothing)
/// removes whitespace before a block and whitespace and a single newline after a block
public static let smart = TrimBehaviour(leading: .whitespace, trailing: .whitespaceAndOneNewLine)
/// removes all whitespace and newlines before and after a block
public static let all = TrimBehaviour(leading: .whitespaceAndNewLines, trailing: .whitespaceAndNewLines)
static func leadingRegex(trim: Trim) -> NSRegularExpression {
switch trim {
case .nothing:
fatalError("No RegularExpression for none")
case .whitespace:
return Self.leadingWhitespace
case .whitespaceAndOneNewLine:
return Self.leadingWhitespaceAndOneNewLine
case .whitespaceAndNewLines:
return Self.leadingWhitespaceAndNewlines
}
}
static func trailingRegex(trim: Trim) -> NSRegularExpression {
switch trim {
case .nothing:
fatalError("No RegularExpression for none")
case .whitespace:
return Self.trailingWhitespace
case .whitespaceAndOneNewLine:
return Self.trailingWhitespaceAndOneNewLine
case .whitespaceAndNewLines:
return Self.trailingWhitespaceAndNewLines
}
}
// swiftlint:disable force_try
private static let leadingWhitespaceAndNewlines = try! NSRegularExpression(pattern: "^\\s+")
private static let trailingWhitespaceAndNewLines = try! NSRegularExpression(pattern: "\\s+$")
private static let leadingWhitespaceAndOneNewLine = try! NSRegularExpression(pattern: "^[ \t]*\n")
private static let trailingWhitespaceAndOneNewLine = try! NSRegularExpression(pattern: "\n[ \t]*$")
private static let leadingWhitespace = try! NSRegularExpression(pattern: "^[ \t]*")
private static let trailingWhitespace = try! NSRegularExpression(pattern: "[ \t]*$")
}

View File

@@ -1,3 +1,9 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Foundation
typealias Number = Float
@@ -16,8 +22,8 @@ class FilterExpression: Resolvable {
let filterBits = bits[bits.indices.suffix(from: 1)]
do {
filters = try filterBits.map {
let (name, arguments) = parseFilterComponents(token: $0)
filters = try filterBits.map { bit in
let (name, arguments) = parseFilterComponents(token: bit)
let filter = try environment.findFilter(name)
return (filter, arguments)
}
@@ -48,7 +54,8 @@ public struct Variable: Equatable, Resolvable {
/// Resolve the variable in the given context
public func resolve(_ context: Context) throws -> Any? {
if (variable.hasPrefix("'") && variable.hasSuffix("'")) || (variable.hasPrefix("\"") && variable.hasSuffix("\"")) {
if variable.count > 1 &&
((variable.hasPrefix("'") && variable.hasSuffix("'")) || (variable.hasPrefix("\"") && variable.hasSuffix("\""))) {
// String literal
return String(variable[variable.index(after: variable.startIndex) ..< variable.index(before: variable.endIndex)])
}
@@ -71,6 +78,8 @@ public struct Variable: Equatable, Resolvable {
if current == nil {
return nil
} else if let lazyCurrent = current as? LazyValueWrapper {
current = try lazyCurrent.value(context: context)
}
}
@@ -109,6 +118,8 @@ public struct Variable: Equatable, Resolvable {
return object.value(forKey: bit)
}
#endif
} else if let value = context as? DynamicMemberLookup {
return value[dynamicMember: bit]
} else if let value = context {
return Mirror(reflecting: value).getValue(for: bit)
}
@@ -205,13 +216,14 @@ protocol Normalizable {
extension Array: Normalizable {
func normalize() -> Any? {
return map { $0 as Any }
map { $0 as Any }
}
}
// swiftlint:disable:next legacy_objc_type
extension NSArray: Normalizable {
func normalize() -> Any? {
return map { $0 as Any }
map { $0 as Any }
}
}
@@ -270,8 +282,10 @@ protocol AnyOptional {
extension Optional: AnyOptional {
var wrapped: Any? {
switch self {
case let .some(value): return value
case .none: return nil
case let .some(value):
return value
case .none:
return nil
}
}
}

View File

@@ -1,9 +0,0 @@
import Foundation
#if !swift(>=4.2)
extension ArraySlice where Element: Equatable {
func firstIndex(of element: Element) -> Int? {
return index(of: element)
}
}
#endif

View File

@@ -1,6 +1,6 @@
{
"name": "Stencil",
"version": "0.14.0",
"version": "0.15.1",
"summary": "Stencil is a simple and powerful template language for Swift.",
"homepage": "https://stencil.fuller.li",
"license": {
@@ -13,10 +13,10 @@
"social_media_url": "https://twitter.com/kylefuller",
"source": {
"git": "https://github.com/stencilproject/Stencil.git",
"tag": "0.14.0"
"tag": "0.15.1"
},
"source_files": [
"Sources/*.swift"
"Sources/Stencil/*.swift"
],
"platforms": {
"ios": "8.0",
@@ -25,7 +25,6 @@
},
"cocoapods_version": ">= 1.7.0",
"swift_versions": [
"4.2",
"5.0"
],
"requires_arc": true,

View File

@@ -1,8 +0,0 @@
import XCTest
import StencilTests
var tests = [XCTestCaseEntry]()
tests += StencilTests.__allTests()
XCTMain(tests)

View File

@@ -1,3 +1,5 @@
parent_config: ../../.swiftlint.yml
disabled_rules: # rule identifiers to exclude from running
- type_body_length
- file_length

View File

@@ -1,38 +1,44 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Spectre
@testable import Stencil
import XCTest
final class ContextTests: XCTestCase {
func testContextSubscripting() {
describe("Context Subscripting") {
describe("Context Subscripting") { test in
var context = Context()
$0.before {
test.before {
context = Context(dictionary: ["name": "Kyle"])
}
$0.it("allows you to get a value via subscripting") {
test.it("allows you to get a value via subscripting") {
try expect(context["name"] as? String) == "Kyle"
}
$0.it("allows you to set a value via subscripting") {
test.it("allows you to set a value via subscripting") {
context["name"] = "Katie"
try expect(context["name"] as? String) == "Katie"
}
$0.it("allows you to remove a value via subscripting") {
test.it("allows you to remove a value via subscripting") {
context["name"] = nil
try expect(context["name"]).to.beNil()
}
$0.it("allows you to retrieve a value from a parent") {
test.it("allows you to retrieve a value from a parent") {
try context.push {
try expect(context["name"] as? String) == "Kyle"
}
}
$0.it("allows you to override a parent's value") {
test.it("allows you to override a parent's value") {
try context.push {
context["name"] = "Katie"
try expect(context["name"] as? String) == "Katie"
@@ -42,13 +48,13 @@ final class ContextTests: XCTestCase {
}
func testContextRestoration() {
describe("Context Restoration") {
describe("Context Restoration") { test in
var context = Context()
$0.before {
test.before {
context = Context(dictionary: ["name": "Kyle"])
}
$0.it("allows you to pop to restore previous state") {
test.it("allows you to pop to restore previous state") {
context.push {
context["name"] = "Katie"
}
@@ -56,7 +62,7 @@ final class ContextTests: XCTestCase {
try expect(context["name"] as? String) == "Kyle"
}
$0.it("allows you to remove a parent's value in a level") {
test.it("allows you to remove a parent's value in a level") {
try context.push {
context["name"] = nil
try expect(context["name"]).to.beNil()
@@ -65,7 +71,7 @@ final class ContextTests: XCTestCase {
try expect(context["name"] as? String) == "Kyle"
}
$0.it("allows you to push a dictionary and run a closure then restoring previous state") {
test.it("allows you to push a dictionary and run a closure then restoring previous state") {
var didRun = false
try context.push(dictionary: ["name": "Katie"]) {
@@ -77,7 +83,7 @@ final class ContextTests: XCTestCase {
try expect(context["name"] as? String) == "Kyle"
}
$0.it("allows you to flatten the context contents") {
test.it("allows you to flatten the context contents") {
try context.push(dictionary: ["test": "abc"]) {
let flattened = context.flatten()
@@ -88,4 +94,73 @@ final class ContextTests: XCTestCase {
}
}
}
func testContextLazyEvaluation() {
let ticker = Ticker()
var context = Context()
var wrapper = LazyValueWrapper("")
describe("Lazy evaluation") { test in
test.before {
ticker.count = 0
wrapper = LazyValueWrapper(ticker.tick())
context = Context(dictionary: ["name": wrapper])
}
test.it("Evaluates lazy data") {
let template = Template(templateString: "{{ name }}")
let result = try template.render(context)
try expect(result) == "Kyle"
try expect(ticker.count) == 1
}
test.it("Evaluates lazy only once") {
let template = Template(templateString: "{{ name }}{{ name }}")
let result = try template.render(context)
try expect(result) == "KyleKyle"
try expect(ticker.count) == 1
}
test.it("Does not evaluate lazy data when not used") {
let template = Template(templateString: "{{ 'Katie' }}")
let result = try template.render(context)
try expect(result) == "Katie"
try expect(ticker.count) == 0
}
}
}
func testContextLazyAccessTypes() {
it("Supports evaluation via context reference") {
let context = Context(dictionary: ["name": "Kyle"])
context["alias"] = LazyValueWrapper { $0["name"] ?? "" }
let template = Template(templateString: "{{ alias }}")
try context.push(dictionary: ["name": "Katie"]) {
let result = try template.render(context)
try expect(result) == "Katie"
}
}
it("Supports evaluation via context copy") {
let context = Context(dictionary: ["name": "Kyle"])
context["alias"] = LazyValueWrapper(copying: context) { $0["name"] ?? "" }
let template = Template(templateString: "{{ alias }}")
try context.push(dictionary: ["name": "Katie"]) {
let result = try template.render(context)
try expect(result) == "Kyle"
}
}
}
}
// MARK: - Helpers
private final class Ticker {
var count: Int = 0
func tick() -> String {
count += 1
return "Kyle"
}
}

View File

@@ -0,0 +1,131 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import PathKit
import Spectre
@testable import Stencil
import XCTest
final class EnvironmentBaseAndChildTemplateTests: XCTestCase {
private var environment = Environment(loader: ExampleLoader())
private var childTemplate: Template = ""
private var baseTemplate: Template = ""
override func setUp() {
super.setUp()
let path = Path(#file as String) + ".." + "fixtures"
let loader = FileSystemLoader(paths: [path])
environment = Environment(loader: loader)
childTemplate = ""
baseTemplate = ""
}
override func tearDown() {
super.tearDown()
}
func testSyntaxErrorInBaseTemplate() throws {
childTemplate = try environment.loadTemplate(name: "invalid-child-super.html")
baseTemplate = try environment.loadTemplate(name: "invalid-base.html")
try expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
childToken: "extends \"invalid-base.html\"",
baseToken: "target|unknown"
)
}
func testRuntimeErrorInBaseTemplate() throws {
let filterExtension = Extension()
filterExtension.registerFilter("unknown") { (_: Any?) in
throw TemplateSyntaxError("filter error")
}
environment.extensions += [filterExtension]
childTemplate = try environment.loadTemplate(name: "invalid-child-super.html")
baseTemplate = try environment.loadTemplate(name: "invalid-base.html")
try expectError(
reason: "filter error",
childToken: "extends \"invalid-base.html\"",
baseToken: "target|unknown"
)
}
func testSyntaxErrorInChildTemplate() throws {
childTemplate = Template(
templateString: """
{% extends "base.html" %}
{% block body %}Child {{ target|unknown }}{% endblock %}
""",
environment: environment,
name: nil
)
try expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
childToken: "target|unknown",
baseToken: nil
)
}
func testRuntimeErrorInChildTemplate() throws {
let filterExtension = Extension()
filterExtension.registerFilter("unknown") { (_: Any?) in
throw TemplateSyntaxError("filter error")
}
environment.extensions += [filterExtension]
childTemplate = Template(
templateString: """
{% extends "base.html" %}
{% block body %}Child {{ target|unknown }}{% endblock %}
""",
environment: environment,
name: nil
)
try expectError(
reason: "filter error",
childToken: "target|unknown",
baseToken: nil
)
}
private func expectError(
reason: String,
childToken: String,
baseToken: String?,
file: String = #file,
line: Int = #line,
function: String = #function
) throws {
var expectedError = expectedSyntaxError(token: childToken, template: childTemplate, description: reason)
if let baseToken = baseToken {
expectedError.stackTrace = [
expectedSyntaxError(
token: baseToken,
template: baseTemplate,
description: reason
).token
].compactMap { $0 }
}
let error = try expect(
self.environment.render(template: self.childTemplate, context: ["target": "World"]),
file: file,
line: line,
function: function
).toThrow() as TemplateSyntaxError
let reporter = SimpleErrorReporter()
try expect(
reporter.renderError(error),
file: file,
line: line,
function: function
) == reporter.renderError(expectedError)
}
}

View File

@@ -0,0 +1,94 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import PathKit
import Spectre
@testable import Stencil
import XCTest
final class EnvironmentIncludeTemplateTests: XCTestCase {
private var environment = Environment(loader: ExampleLoader())
private var template: Template = ""
private var includedTemplate: Template = ""
override func setUp() {
super.setUp()
let path = Path(#file as String) + ".." + "fixtures"
let loader = FileSystemLoader(paths: [path])
environment = Environment(loader: loader)
template = ""
includedTemplate = ""
}
override func tearDown() {
super.tearDown()
}
func testSyntaxError() throws {
template = Template(templateString: """
{% include "invalid-include.html" %}
""", environment: environment)
includedTemplate = try environment.loadTemplate(name: "invalid-include.html")
try expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
token: #"include "invalid-include.html""#,
includedToken: "target|unknown"
)
}
func testRuntimeError() throws {
let filterExtension = Extension()
filterExtension.registerFilter("unknown") { (_: Any?) in
throw TemplateSyntaxError("filter error")
}
environment.extensions += [filterExtension]
template = Template(templateString: """
{% include "invalid-include.html" %}
""", environment: environment)
includedTemplate = try environment.loadTemplate(name: "invalid-include.html")
try expectError(
reason: "filter error",
token: "include \"invalid-include.html\"",
includedToken: "target|unknown"
)
}
private func expectError(
reason: String,
token: String,
includedToken: String,
file: String = #file,
line: Int = #line,
function: String = #function
) throws {
var expectedError = expectedSyntaxError(token: token, template: template, description: reason)
expectedError.stackTrace = [
expectedSyntaxError(
token: includedToken,
template: includedTemplate,
description: reason
).token
].compactMap { $0 }
let error = try expect(
self.environment.render(template: self.template, context: ["target": "World"]),
file: file,
line: line,
function: function
).toThrow() as TemplateSyntaxError
let reporter = SimpleErrorReporter()
try expect(
reporter.renderError(error),
file: file,
line: line,
function: function
) == reporter.renderError(expectedError)
}
}

View File

@@ -1,11 +1,17 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import PathKit
import Spectre
@testable import Stencil
import XCTest
final class EnvironmentTests: XCTestCase {
var environment = Environment(loader: ExampleLoader())
var template: Template = ""
private var environment = Environment(loader: ExampleLoader())
private var template: Template = ""
override func setUp() {
super.setUp()
@@ -26,6 +32,10 @@ final class EnvironmentTests: XCTestCase {
template = ""
}
override func tearDown() {
super.tearDown()
}
func testLoading() {
it("can load a template from a name") {
let template = try self.environment.loadTemplate(name: "example.html")
@@ -207,242 +217,11 @@ final class EnvironmentTests: XCTestCase {
}
}
final class EnvironmentIncludeTemplateTests: XCTestCase {
var environment = Environment(loader: ExampleLoader())
var template: Template = ""
var includedTemplate: Template = ""
override func setUp() {
super.setUp()
let path = Path(#file as String) + ".." + "fixtures"
let loader = FileSystemLoader(paths: [path])
environment = Environment(loader: loader)
template = ""
includedTemplate = ""
}
func testSyntaxError() throws {
template = Template(templateString: """
{% include "invalid-include.html" %}
""", environment: environment)
includedTemplate = try environment.loadTemplate(name: "invalid-include.html")
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
token: """
include "invalid-include.html"
""",
includedToken: "target|unknown")
}
func testRuntimeError() throws {
let filterExtension = Extension()
filterExtension.registerFilter("unknown") { (_: Any?) in
throw TemplateSyntaxError("filter error")
}
environment.extensions += [filterExtension]
template = Template(templateString: """
{% include "invalid-include.html" %}
""", environment: environment)
includedTemplate = try environment.loadTemplate(name: "invalid-include.html")
try expectError(reason: "filter error",
token: "include \"invalid-include.html\"",
includedToken: "target|unknown")
}
private func expectError(
reason: String,
token: String,
includedToken: String,
file: String = #file,
line: Int = #line,
function: String = #function
) throws {
var expectedError = expectedSyntaxError(token: token, template: template, description: reason)
expectedError.stackTrace = [
expectedSyntaxError(
token: includedToken,
template: includedTemplate,
description: reason
).token
].compactMap { $0 }
let error = try expect(
self.environment.render(template: self.template, context: ["target": "World"]),
file: file,
line: line,
function: function
).toThrow() as TemplateSyntaxError
let reporter = SimpleErrorReporter()
try expect(
reporter.renderError(error),
file: file,
line: line,
function: function
) == reporter.renderError(expectedError)
}
}
final class EnvironmentBaseAndChildTemplateTests: XCTestCase {
var environment = Environment(loader: ExampleLoader())
var childTemplate: Template = ""
var baseTemplate: Template = ""
override func setUp() {
super.setUp()
let path = Path(#file as String) + ".." + "fixtures"
let loader = FileSystemLoader(paths: [path])
environment = Environment(loader: loader)
childTemplate = ""
baseTemplate = ""
}
func testSyntaxErrorInBaseTemplate() throws {
childTemplate = try environment.loadTemplate(name: "invalid-child-super.html")
baseTemplate = try environment.loadTemplate(name: "invalid-base.html")
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
childToken: "extends \"invalid-base.html\"",
baseToken: "target|unknown")
}
func testRuntimeErrorInBaseTemplate() throws {
let filterExtension = Extension()
filterExtension.registerFilter("unknown") { (_: Any?) in
throw TemplateSyntaxError("filter error")
}
environment.extensions += [filterExtension]
childTemplate = try environment.loadTemplate(name: "invalid-child-super.html")
baseTemplate = try environment.loadTemplate(name: "invalid-base.html")
try expectError(reason: "filter error",
childToken: "block.super",
baseToken: "target|unknown")
}
func testSyntaxErrorInChildTemplate() throws {
childTemplate = Template(
templateString: """
{% extends "base.html" %}
{% block body %}Child {{ target|unknown }}{% endblock %}
""",
environment: environment,
name: nil
)
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
childToken: "target|unknown",
baseToken: nil)
}
func testRuntimeErrorInChildTemplate() throws {
let filterExtension = Extension()
filterExtension.registerFilter("unknown") { (_: Any?) in
throw TemplateSyntaxError("filter error")
}
environment.extensions += [filterExtension]
childTemplate = Template(
templateString: """
{% extends "base.html" %}
{% block body %}Child {{ target|unknown }}{% endblock %}
""",
environment: environment,
name: nil
)
try expectError(reason: "filter error",
childToken: "target|unknown",
baseToken: nil)
}
private func expectError(
reason: String,
childToken: String,
baseToken: String?,
file: String = #file,
line: Int = #line,
function: String = #function
) throws {
var expectedError = expectedSyntaxError(token: childToken, template: childTemplate, description: reason)
if let baseToken = baseToken {
expectedError.stackTrace = [
expectedSyntaxError(
token: baseToken,
template: baseTemplate,
description: reason
).token
].compactMap { $0 }
}
let error = try expect(
self.environment.render(template: self.childTemplate, context: ["target": "World"]),
file: file,
line: line,
function: function
).toThrow() as TemplateSyntaxError
let reporter = SimpleErrorReporter()
try expect(
reporter.renderError(error),
file: file,
line: line,
function: function
) == reporter.renderError(expectedError)
}
}
extension Expectation {
@discardableResult
func toThrow<T: Error>() throws -> T {
var thrownError: Error?
do {
_ = try expression()
} catch {
thrownError = error
}
if let thrownError = thrownError {
if let thrownError = thrownError as? T {
return thrownError
} else {
throw failure("\(thrownError) is not \(T.self)")
}
} else {
throw failure("expression did not throw an error")
}
}
}
extension XCTestCase {
func expectedSyntaxError(token: String, template: Template, description: String) -> TemplateSyntaxError {
guard let range = template.templateString.range(of: token) else {
fatalError("Can't find '\(token)' in '\(template)'")
}
let lexer = Lexer(templateString: template.templateString)
let location = lexer.rangeLocation(range)
let sourceMap = SourceMap(filename: template.name, location: location)
let token = Token.block(value: token, at: sourceMap)
return TemplateSyntaxError(reason: description, token: token, stackTrace: [])
}
}
private class ExampleLoader: Loader {
func loadTemplate(name: String, environment: Environment) throws -> Template {
if name == "example.html" {
return Template(templateString: "Hello World!", environment: environment, name: name)
}
throw TemplateDoesNotExist(templateNames: [name], loader: self)
}
}
// MARK: - Helpers
private class CustomTemplate: Template {
// swiftlint:disable discouraged_optional_collection
override func render(_ dictionary: [String: Any]? = nil) throws -> String {
return "here"
"here"
}
}

View File

@@ -1,9 +1,15 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Spectre
@testable import Stencil
import XCTest
final class ExpressionsTests: XCTestCase {
let parser = TokenParser(tokens: [], environment: Environment())
private let parser = TokenParser(tokens: [], environment: Environment())
private func makeExpression(_ components: [String]) -> Expression {
do {
@@ -115,12 +121,12 @@ final class ExpressionsTests: XCTestCase {
func testNotExpression() {
it("returns truthy for positive expressions") {
let expression = NotExpression(expression: StaticExpression(value: true))
let expression = NotExpression(expression: VariableExpression(variable: Variable("true")))
try expect(expression.evaluate(context: Context())).to.beFalse()
}
it("returns falsy for negative expressions") {
let expression = NotExpression(expression: StaticExpression(value: false))
let expression = NotExpression(expression: VariableExpression(variable: Variable("false")))
try expect(expression.evaluate(context: Context())).to.beTrue()
}
}

View File

@@ -1,3 +1,9 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Spectre
@testable import Stencil
import XCTest
@@ -363,10 +369,12 @@ final class FilterTests: XCTestCase {
Two
"""
]))
// swiftlint:disable indentation_width
try expect(result) == """
One
Two
"""
// swiftlint:enable indentation_width
}
func testIndentNotEmptyLines() throws {
@@ -383,6 +391,7 @@ final class FilterTests: XCTestCase {
"""
]))
// swiftlint:disable indentation_width
try expect(result) == """
One
@@ -391,6 +400,7 @@ final class FilterTests: XCTestCase {
"""
// swiftlint:enable indentation_width
}
func testDynamicFilters() throws {

View File

@@ -1,3 +1,9 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Spectre
import Stencil
import XCTest
@@ -23,9 +29,9 @@ final class FilterTagTests: XCTestCase {
it("can render filters with arguments") {
let ext = Extension()
ext.registerFilter("split") {
guard let value = $0 as? String,
let argument = $1.first as? String else { return $0 }
ext.registerFilter("split") { value, args in
guard let value = value as? String,
let argument = args.first as? String else { return value }
return value.components(separatedBy: argument)
}
let env = Environment(extensions: [ext])
@@ -37,11 +43,11 @@ final class FilterTagTests: XCTestCase {
it("can render filters with quote as an argument") {
let ext = Extension()
ext.registerFilter("replace") {
guard let value = $0 as? String,
$1.count == 2,
let search = $1.first as? String,
let replacement = $1.last as? String else { return $0 }
ext.registerFilter("replace") { value, args in
guard let value = value as? String,
args.count == 2,
let search = args.first as? String,
let replacement = args.last as? String else { return value }
return value.replacingOccurrences(of: search, with: replacement)
}
let env = Environment(extensions: [ext])

View File

@@ -1,11 +1,18 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Spectre
@testable import Stencil
import XCTest
final class ForNodeTests: XCTestCase {
let context = Context(dictionary: [
private let context = Context(dictionary: [
"items": [1, 2, 3],
"anyItems": [1, 2, 3] as [Any],
// swiftlint:disable:next legacy_objc_type
"nsItems": NSArray(array: [1, 2, 3]),
"emptyItems": [Int](),
"dict": [
@@ -311,8 +318,257 @@ final class ForNodeTests: XCTestCase {
)
try expect(try parser.parse()).toThrow(error)
}
func testBreak() {
it("can break from loop") {
let template = Template(templateString: """
{% for item in items %}\
{{ item }}{% break %}\
{% endfor %}
""")
try expect(template.render(self.context)) == """
1
"""
}
it("can break from inner node") {
let template = Template(templateString: """
{% for item in items %}\
{{ item }}\
{% if forloop.first %}<{% break %}>{% endif %}!\
{% endfor %}
""")
try expect(template.render(self.context)) == """
1<
"""
}
it("does not allow break outside loop") {
let template = Template(templateString: "{% for item in items %}{% endfor %}{% break %}")
let error = self.expectedSyntaxError(
token: "break",
template: template,
description: "'break' can be used only inside loop body"
)
try expect(template.render(self.context)).toThrow(error)
}
}
func testBreakNested() {
it("breaks outer loop") {
let template = Template(templateString: """
{% for item in items %}\
outer: {{ item }}
{% for item in items %}\
inner: {{ item }}
{% endfor %}\
{% break %}\
{% endfor %}
""")
try expect(template.render(self.context)) == """
outer: 1
inner: 1
inner: 2
inner: 3
"""
}
it("breaks inner loop") {
let template = Template(templateString: """
{% for item in items %}\
outer: {{ item }}
{% for item in items %}\
inner: {{ item }}
{% break %}\
{% endfor %}\
{% endfor %}
""")
try expect(template.render(self.context)) == """
outer: 1
inner: 1
outer: 2
inner: 1
outer: 3
inner: 1
"""
}
}
func testBreakLabeled() {
it("breaks labeled loop") {
let template = Template(templateString: """
{% outer: for item in items %}\
outer: {{ item }}
{% for item in items %}\
{% break outer %}\
inner: {{ item }}
{% endfor %}\
{% endfor %}
""")
try expect(template.render(self.context)) == """
outer: 1
"""
}
it("throws when breaking with unknown label") {
let template = Template(templateString: """
{% outer: for item in items %}
{% break inner %}
{% endfor %}
""")
try expect(template.render(self.context)).toThrow()
}
}
func testContinue() {
it("can continue loop") {
let template = Template(templateString: """
{% for item in items %}\
{{ item }}{% continue %}!\
{% endfor %}
""")
try expect(template.render(self.context)) == "123"
}
it("can continue from inner node") {
let template = Template(templateString: """
{% for item in items %}\
{% if forloop.last %}<{% continue %}>{% endif %}!\
{{ item }}\
{% endfor %}
""")
try expect(template.render(self.context)) == "!1!2<"
}
it("does not allow continue outside loop") {
let template = Template(templateString: "{% for item in items %}{% endfor %}{% continue %}")
let error = self.expectedSyntaxError(
token: "continue",
template: template,
description: "'continue' can be used only inside loop body"
)
try expect(template.render(self.context)).toThrow(error)
}
}
func testContinueNested() {
it("breaks outer loop") {
let template = Template(templateString: """
{% for item in items %}\
{% for item in items %}\
inner: {{ item }}\
{% endfor %}
{% continue %}
outer: {{ item }}
{% endfor %}
""")
try expect(template.render(self.context)) == """
inner: 1inner: 2inner: 3
inner: 1inner: 2inner: 3
inner: 1inner: 2inner: 3
"""
}
it("breaks inner loop") {
let template = Template(templateString: """
{% for item in items %}\
{% for item in items %}\
{% continue %}\
inner: {{ item }}
{% endfor %}\
outer: {{ item }}
{% endfor %}
""")
try expect(template.render(self.context)) == """
outer: 1
outer: 2
outer: 3
"""
}
}
func testContinueLabeled() {
it("continues labeled loop") {
let template = Template(templateString: """
{% outer: for item in items %}\
{% for item in items %}\
inner: {{ item }}
{% continue outer %}\
{% endfor %}\
outer: {{ item }}
{% endfor %}
""")
try expect(template.render(self.context)) == """
inner: 1
inner: 1
inner: 1
"""
}
it("throws when continuing with unknown label") {
let template = Template(templateString: """
{% outer: for item in items %}
{% continue inner %}
{% endfor %}
""")
try expect(template.render(self.context)).toThrow()
}
}
func testAccessLabeled() {
it("can access labeled outer loop context from inner loop") {
let template = Template(templateString: """
{% outer: for item in 1...2 %}\
{% for item in items %}\
{{ forloop.counter }}-{{ forloop.outer.counter }},\
{% endfor %}---\
{% endfor %}
""")
try expect(template.render(self.context)) == """
1-1,2-1,3-1,---1-2,2-2,3-2,---
"""
}
it("can access labeled outer loop from double inner loop") {
let template = Template(templateString: """
{% outer: for item in 1...2 %}{% for item in 1...2 %}\
{% for item in items %}\
{{ forloop.counter }}-{{ forloop.outer.counter }},\
{% endfor %}---{% endfor %}
{% endfor %}
""")
try expect(template.render(self.context)) == """
1-1,2-1,3-1,---1-1,2-1,3-1,---
1-2,2-2,3-2,---1-2,2-2,3-2,---
"""
}
it("can access two labeled outer loop contexts from inner loop") {
let template = Template(templateString: """
{% outer1: for item in 1...2 %}{% outer2: for item in 1...2 %}\
{% for item in items %}\
{{ forloop.counter }}-{{ forloop.outer2.counter }}-{{ forloop.outer1.counter }},\
{% endfor %}---{% endfor %}
{% endfor %}
""")
try expect(template.render(self.context)) == """
1-1-1,2-1-1,3-1-1,---1-2-1,2-2-1,3-2-1,---
1-1-2,2-1-2,3-1-2,---1-2-2,2-2-2,3-2-2,---
"""
}
}
}
// MARK: - Helpers
private struct MyStruct {
let string: String
let number: Int

View File

@@ -0,0 +1,69 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import PathKit
import Spectre
@testable import Stencil
import XCTest
extension Expectation {
@discardableResult
func toThrow<T: Error>() throws -> T {
var thrownError: Error?
do {
_ = try expression()
} catch {
thrownError = error
}
if let thrownError = thrownError {
if let thrownError = thrownError as? T {
return thrownError
} else {
throw failure("\(thrownError) is not \(T.self)")
}
} else {
throw failure("expression did not throw an error")
}
}
}
extension XCTestCase {
func expectedSyntaxError(token: String, template: Template, description: String) -> TemplateSyntaxError {
guard let range = template.templateString.range(of: token) else {
fatalError("Can't find '\(token)' in '\(template)'")
}
let lexer = Lexer(templateString: template.templateString)
let location = lexer.rangeLocation(range)
let sourceMap = SourceMap(filename: template.name, location: location)
let token = Token.block(value: token, at: sourceMap)
return TemplateSyntaxError(reason: description, token: token, stackTrace: [])
}
}
// MARK: - Test Types
class ExampleLoader: Loader {
func loadTemplate(name: String, environment: Environment) throws -> Template {
if name == "example.html" {
return Template(templateString: "Hello World!", environment: environment, name: name)
}
throw TemplateDoesNotExist(templateNames: [name], loader: self)
}
}
class ErrorNode: NodeType {
let token: Token?
init(token: Token? = nil) {
self.token = token
}
func render(_ context: Context) throws -> String {
throw TemplateSyntaxError("Custom Error")
}
}

View File

@@ -1,11 +1,13 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Spectre
@testable import Stencil
import XCTest
private struct SomeType {
let value: String? = nil
}
final class IfNodeTests: XCTestCase {
func testParseIf() {
it("can parse an if block") {
@@ -204,8 +206,8 @@ final class IfNodeTests: XCTestCase {
func testRendering() {
it("renders a true expression") {
let node = IfNode(conditions: [
IfCondition(expression: StaticExpression(value: true), nodes: [TextNode(text: "1")]),
IfCondition(expression: StaticExpression(value: true), nodes: [TextNode(text: "2")]),
IfCondition(expression: VariableExpression(variable: Variable("true")), nodes: [TextNode(text: "1")]),
IfCondition(expression: VariableExpression(variable: Variable("true")), nodes: [TextNode(text: "2")]),
IfCondition(expression: nil, nodes: [TextNode(text: "3")])
])
@@ -214,8 +216,8 @@ final class IfNodeTests: XCTestCase {
it("renders the first true expression") {
let node = IfNode(conditions: [
IfCondition(expression: StaticExpression(value: false), nodes: [TextNode(text: "1")]),
IfCondition(expression: StaticExpression(value: true), nodes: [TextNode(text: "2")]),
IfCondition(expression: VariableExpression(variable: Variable("false")), nodes: [TextNode(text: "1")]),
IfCondition(expression: VariableExpression(variable: Variable("true")), nodes: [TextNode(text: "2")]),
IfCondition(expression: nil, nodes: [TextNode(text: "3")])
])
@@ -224,8 +226,8 @@ final class IfNodeTests: XCTestCase {
it("renders the empty expression when other conditions are falsy") {
let node = IfNode(conditions: [
IfCondition(expression: StaticExpression(value: false), nodes: [TextNode(text: "1")]),
IfCondition(expression: StaticExpression(value: false), nodes: [TextNode(text: "2")]),
IfCondition(expression: VariableExpression(variable: Variable("false")), nodes: [TextNode(text: "1")]),
IfCondition(expression: VariableExpression(variable: Variable("false")), nodes: [TextNode(text: "2")]),
IfCondition(expression: nil, nodes: [TextNode(text: "3")])
])
@@ -234,8 +236,8 @@ final class IfNodeTests: XCTestCase {
it("renders empty when no truthy conditions") {
let node = IfNode(conditions: [
IfCondition(expression: StaticExpression(value: false), nodes: [TextNode(text: "1")]),
IfCondition(expression: StaticExpression(value: false), nodes: [TextNode(text: "2")])
IfCondition(expression: VariableExpression(variable: Variable("false")), nodes: [TextNode(text: "1")]),
IfCondition(expression: VariableExpression(variable: Variable("false")), nodes: [TextNode(text: "2")])
])
try expect(try node.render(Context())) == ""
@@ -286,3 +288,9 @@ final class IfNodeTests: XCTestCase {
try expect(renderNodes(nodes, Context(dictionary: ["value": 4]))) == "false"
}
}
// MARK: - Helpers
private struct SomeType {
let value: String? = nil
}

View File

@@ -1,12 +1,18 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import PathKit
import Spectre
@testable import Stencil
import XCTest
final class IncludeTests: XCTestCase {
let path = Path(#file as String) + ".." + "fixtures"
lazy var loader = FileSystemLoader(paths: [path])
lazy var environment = Environment(loader: loader)
private let path = Path(#file as String) + ".." + "fixtures"
private lazy var loader = FileSystemLoader(paths: [path])
private lazy var environment = Environment(loader: loader)
func testParsing() {
it("throws an error when no template is given") {

View File

@@ -1,12 +1,18 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import PathKit
import Spectre
import Stencil
import XCTest
final class InheritanceTests: XCTestCase {
let path = Path(#file as String) + ".." + "fixtures"
lazy var loader = FileSystemLoader(paths: [path])
lazy var environment = Environment(loader: loader)
private let path = Path(#file as String) + ".." + "fixtures"
private lazy var loader = FileSystemLoader(paths: [path])
private lazy var environment = Environment(loader: loader)
func testInheritance() {
it("can inherit from another template") {
@@ -32,5 +38,42 @@ final class InheritanceTests: XCTestCase {
Child_Body
"""
}
it("can render block.super in if tag") {
let template = try self.environment.loadTemplate(name: "if-block-child.html")
try expect(try template.render(["sort": "new"])) == """
Title - Nieuwste spellen
"""
try expect(try template.render(["sort": "upcoming"])) == """
Title - Binnenkort op de agenda
"""
try expect(try template.render(["sort": "near-me"])) == """
Title - In mijn buurt
"""
}
}
func testInheritanceCache() {
it("can call block twice") {
let template: Template = "{% block repeat %}Block{% endblock %}{{ block.repeat }}"
try expect(try template.render()) == "BlockBlock"
}
it("renders child content when calling block twice in base template") {
let template = try self.environment.loadTemplate(name: "child-repeat.html")
try expect(try template.render()) == """
Super_Header Child_Header
Child_Body
Repeat
Super_Header Child_Header
Child_Body
"""
}
}
}

View File

@@ -1,3 +1,9 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import PathKit
import Spectre
@testable import Stencil
@@ -51,9 +57,9 @@ final class LexerTests: XCTestCase {
let tokens = lexer.tokenize()
try expect(tokens.count) == 3
try expect(tokens[0]) == Token.text(value: "My name is ", at: makeSourceMap("My name is ", for: lexer))
try expect(tokens[1]) == Token.variable(value: "myname", at: makeSourceMap("myname", for: lexer))
try expect(tokens[2]) == Token.text(value: ".", at: makeSourceMap(".", for: lexer))
try expect(tokens[0]) == .text(value: "My name is ", at: makeSourceMap("My name is ", for: lexer))
try expect(tokens[1]) == .variable(value: "myname", at: makeSourceMap("myname", for: lexer))
try expect(tokens[2]) == .text(value: ".", at: makeSourceMap(".", for: lexer))
}
func testVariablesWithoutBeingGreedy() throws {
@@ -62,8 +68,8 @@ final class LexerTests: XCTestCase {
let tokens = lexer.tokenize()
try expect(tokens.count) == 2
try expect(tokens[0]) == Token.variable(value: "thing", at: makeSourceMap("thing", for: lexer))
try expect(tokens[1]) == Token.variable(value: "name", at: makeSourceMap("name", for: lexer))
try expect(tokens[0]) == .variable(value: "thing", at: makeSourceMap("thing", for: lexer))
try expect(tokens[1]) == .variable(value: "name", at: makeSourceMap("name", for: lexer))
}
func testUnclosedBlock() throws {
@@ -82,6 +88,7 @@ final class LexerTests: XCTestCase {
}
func testNewlines() throws {
// swiftlint:disable indentation_width
let templateString = """
My name is {%
if name
@@ -92,15 +99,31 @@ final class LexerTests: XCTestCase {
}}{%
endif %}.
"""
// swiftlint:enable indentation_width
let lexer = Lexer(templateString: templateString)
let tokens = lexer.tokenize()
try expect(tokens.count) == 5
try expect(tokens[0]) == Token.text(value: "My name is ", at: makeSourceMap("My name is", for: lexer))
try expect(tokens[1]) == Token.block(value: "if name and name", at: makeSourceMap("{%", for: lexer))
try expect(tokens[2]) == Token.variable(value: "name", at: makeSourceMap("name", for: lexer, options: .backwards))
try expect(tokens[3]) == Token.block(value: "endif", at: makeSourceMap("endif", for: lexer))
try expect(tokens[4]) == Token.text(value: ".", at: makeSourceMap(".", for: lexer))
try expect(tokens[0]) == .text(value: "My name is ", at: makeSourceMap("My name is", for: lexer))
try expect(tokens[1]) == .block(value: "if name and name", at: makeSourceMap("{%", for: lexer))
try expect(tokens[2]) == .variable(value: "name", at: makeSourceMap("name", for: lexer, options: .backwards))
try expect(tokens[3]) == .block(value: "endif", at: makeSourceMap("endif", for: lexer))
try expect(tokens[4]) == .text(value: ".", at: makeSourceMap(".", for: lexer))
}
func testTrimSymbols() throws {
let fBlock = "if hello"
let sBlock = "ta da"
let lexer = Lexer(templateString: "{%+ \(fBlock) -%}{% \(sBlock) -%}")
let tokens = lexer.tokenize()
let behaviours = (
WhitespaceBehaviour(leading: .keep, trailing: .trim),
WhitespaceBehaviour(leading: .unspecified, trailing: .trim)
)
try expect(tokens.count) == 2
try expect(tokens[0]) == .block(value: fBlock, at: makeSourceMap(fBlock, for: lexer), whitespace: behaviours.0)
try expect(tokens[1]) == .block(value: sBlock, at: makeSourceMap(sBlock, for: lexer), whitespace: behaviours.1)
}
func testEscapeSequence() throws {
@@ -109,11 +132,11 @@ final class LexerTests: XCTestCase {
let tokens = lexer.tokenize()
try expect(tokens.count) == 5
try expect(tokens[0]) == Token.text(value: "class Some ", at: makeSourceMap("class Some ", for: lexer))
try expect(tokens[1]) == Token.variable(value: "'{'", at: makeSourceMap("'{'", for: lexer))
try expect(tokens[2]) == Token.block(value: "if true", at: makeSourceMap("if true", for: lexer))
try expect(tokens[3]) == Token.variable(value: "stuff", at: makeSourceMap("stuff", for: lexer))
try expect(tokens[4]) == Token.block(value: "endif", at: makeSourceMap("endif", for: lexer))
try expect(tokens[0]) == .text(value: "class Some ", at: makeSourceMap("class Some ", for: lexer))
try expect(tokens[1]) == .variable(value: "'{'", at: makeSourceMap("'{'", for: lexer))
try expect(tokens[2]) == .block(value: "if true", at: makeSourceMap("if true", for: lexer))
try expect(tokens[3]) == .variable(value: "stuff", at: makeSourceMap("stuff", for: lexer))
try expect(tokens[4]) == .block(value: "endif", at: makeSourceMap("endif", for: lexer))
}
func testPerformance() throws {

View File

@@ -1,3 +1,9 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import PathKit
import Spectre
import Stencil

View File

@@ -1,20 +1,15 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Spectre
@testable import Stencil
import XCTest
class ErrorNode: NodeType {
let token: Token?
init(token: Token? = nil) {
self.token = token
}
func render(_ context: Context) throws -> String {
throw TemplateSyntaxError("Custom Error")
}
}
final class NodeTests: XCTestCase {
let context = Context(dictionary: [
private let context = Context(dictionary: [
"name": "Kyle",
"age": 27,
"items": [1, 2, 3]
@@ -25,6 +20,48 @@ final class NodeTests: XCTestCase {
let node = TextNode(text: "Hello World")
try expect(try node.render(self.context)) == "Hello World"
}
it("Trims leading whitespace") {
let text = " \n Some text "
let trimBehaviour = TrimBehaviour(leading: .whitespace, trailing: .nothing)
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
try expect(try node.render(self.context)) == "\n Some text "
}
it("Trims leading whitespace and one newline") {
let text = "\n\n Some text "
let trimBehaviour = TrimBehaviour(leading: .whitespaceAndOneNewLine, trailing: .nothing)
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
try expect(try node.render(self.context)) == "\n Some text "
}
it("Trims leading whitespace and one newline") {
let text = "\n\n Some text "
let trimBehaviour = TrimBehaviour(leading: .whitespaceAndNewLines, trailing: .nothing)
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
try expect(try node.render(self.context)) == "Some text "
}
it("Trims trailing whitespace") {
let text = " Some text \n"
let trimBehaviour = TrimBehaviour(leading: .nothing, trailing: .whitespace)
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
try expect(try node.render(self.context)) == " Some text\n"
}
it("Trims trailing whitespace and one newline") {
let text = " Some text \n \n "
let trimBehaviour = TrimBehaviour(leading: .nothing, trailing: .whitespaceAndOneNewLine)
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
try expect(try node.render(self.context)) == " Some text \n "
}
it("Trims trailing whitespace and newlines") {
let text = " Some text \n \n "
let trimBehaviour = TrimBehaviour(leading: .nothing, trailing: .whitespaceAndNewLines)
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
try expect(try node.render(self.context)) == " Some text"
}
it("Trims all whitespace") {
let text = " \n \nSome text \n "
let trimBehaviour = TrimBehaviour(leading: .whitespaceAndNewLines, trailing: .whitespaceAndNewLines)
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
try expect(try node.render(self.context)) == "Some text"
}
}
func testVariableNode() {
@@ -59,4 +96,22 @@ final class NodeTests: XCTestCase {
try expect(try renderNodes(nodes, self.context)).toThrow(TemplateSyntaxError("Custom Error"))
}
}
func testRenderingBooleans() {
it("can render true & false") {
try expect(Template(templateString: "{{ true }}").render()) == "true"
try expect(Template(templateString: "{{ false }}").render()) == "false"
}
it("can resolve variable") {
let template = Template(templateString: "{{ value == \"known\" }}")
try expect(template.render(["value": "known"])) == "true"
try expect(template.render(["value": "unknown"])) == "false"
}
it("can render a boolean expression") {
try expect(Template(templateString: "{{ 1 > 0 }}").render()) == "true"
try expect(Template(templateString: "{{ 1 == 2 }}").render()) == "false"
}
}
}

View File

@@ -1,3 +1,9 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Spectre
@testable import Stencil
import XCTest
@@ -5,9 +11,9 @@ import XCTest
final class NowNodeTests: XCTestCase {
func testParsing() {
it("parses default format without any now arguments") {
#if os(Linux)
#if os(Linux)
throw skip()
#else
#else
let tokens: [Token] = [ .block(value: "now", at: .unknown) ]
let parser = TokenParser(tokens: tokens, environment: Environment())
@@ -15,36 +21,36 @@ final class NowNodeTests: XCTestCase {
let node = nodes.first as? NowNode
try expect(nodes.count) == 1
try expect(node?.format.variable) == "\"yyyy-MM-dd 'at' HH:mm\""
#endif
#endif
}
it("parses now with a format") {
#if os(Linux)
#if os(Linux)
throw skip()
#else
#else
let tokens: [Token] = [ .block(value: "now \"HH:mm\"", at: .unknown) ]
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
let node = nodes.first as? NowNode
try expect(nodes.count) == 1
try expect(node?.format.variable) == "\"HH:mm\""
#endif
#endif
}
}
func testRendering() {
it("renders the date") {
#if os(Linux)
#if os(Linux)
throw skip()
#else
#else
let node = NowNode(format: Variable("\"yyyy-MM-dd\""))
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
let date = formatter.string(from: NSDate() as Date)
let date = formatter.string(from: Date())
try expect(try node.render(Context())) == date
#endif
#endif
}
}
}

View File

@@ -1,10 +1,15 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Spectre
@testable import Stencil
import XCTest
final class TokenParserTests: XCTestCase {
func testTokenParser() {
it("can parse a text token") {
func testTextToken() throws {
let parser = TokenParser(tokens: [
.text(value: "Hello World", at: .unknown)
], environment: Environment())
@@ -16,7 +21,7 @@ final class TokenParserTests: XCTestCase {
try expect(node?.text) == "Hello World"
}
it("can parse a variable token") {
func testVariableToken() throws {
let parser = TokenParser(tokens: [
.variable(value: "'name'", at: .unknown)
], environment: Environment())
@@ -28,7 +33,7 @@ final class TokenParserTests: XCTestCase {
try expect(result) == "name"
}
it("can parse a comment token") {
func testCommentToken() throws {
let parser = TokenParser(tokens: [
.comment(value: "Secret stuff!", at: .unknown)
], environment: Environment())
@@ -37,7 +42,7 @@ final class TokenParserTests: XCTestCase {
try expect(nodes.count) == 0
}
it("can parse a tag token") {
func testTagToken() throws {
let simpleExtension = Extension()
simpleExtension.registerSimpleTag("known") { _ in
""
@@ -51,14 +56,30 @@ final class TokenParserTests: XCTestCase {
try expect(nodes.count) == 1
}
it("errors when parsing an unknown tag") {
func testErrorUnknownTag() throws {
let tokens: [Token] = [.block(value: "unknown", at: .unknown)]
let parser = TokenParser(tokens: tokens, environment: Environment())
try expect(try parser.parse()).toThrow(TemplateSyntaxError(
reason: "Unknown template tag 'unknown'",
token: tokens.first)
)
token: tokens.first
))
}
func testTransformWhitespaceBehaviourToTrimBehaviour() throws {
let simpleExtension = Extension()
simpleExtension.registerSimpleTag("known") { _ in "" }
let parser = TokenParser(tokens: [
.block(value: "known", at: .unknown, whitespace: WhitespaceBehaviour(leading: .unspecified, trailing: .trim)),
.text(value: " \nSome text ", at: .unknown),
.block(value: "known", at: .unknown, whitespace: WhitespaceBehaviour(leading: .keep, trailing: .trim))
], environment: Environment(extensions: [simpleExtension]))
let nodes = try parser.parse()
try expect(nodes.count) == 3
let textNode = nodes[1] as? TextNode
try expect(textNode?.text) == " \nSome text "
try expect(textNode?.trimBehaviour) == TrimBehaviour(leading: .whitespaceAndNewLines, trailing: .nothing)
}
}

View File

@@ -1,21 +1,15 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Spectre
import Stencil
import XCTest
private struct CustomNode: NodeType {
let token: Token?
func render(_ context: Context) throws -> String {
return "Hello World"
}
}
private struct Article {
let title: String
let author: String
}
final class StencilTests: XCTestCase {
lazy var environment: Environment = {
private lazy var environment: Environment = {
let exampleExtension = Extension()
exampleExtension.registerSimpleTag("simpletag") { _ in
"Hello World"
@@ -66,3 +60,17 @@ final class StencilTests: XCTestCase {
}
}
}
// MARK: - Helpers
private struct CustomNode: NodeType {
let token: Token?
func render(_ context: Context) throws -> String {
"Hello World"
}
}
private struct Article {
let title: String
let author: String
}

View File

@@ -1,3 +1,9 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Spectre
@testable import Stencil
import XCTest

View File

@@ -1,3 +1,9 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Spectre
@testable import Stencil
import XCTest

View File

@@ -0,0 +1,143 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Spectre
import Stencil
import XCTest
final class TrimBehaviourTests: XCTestCase {
func testSmartTrimCanRemoveNewlines() throws {
let templateString = """
{% for item in items %}
- {{item}}
{% endfor %}
text
"""
let context = ["items": ["item 1", "item 2"]]
let template = Template(templateString: templateString, environment: .init(trimBehaviour: .smart))
let result = try template.render(context)
// swiftlint:disable indentation_width
try expect(result) == """
- item 1
- item 2
text
"""
// swiftlint:enable indentation_width
}
func testSmartTrimOnlyRemoveSingleNewlines() throws {
let templateString = """
{% for item in items %}
- {{item}}
{% endfor %}
text
"""
let context = ["items": ["item 1", "item 2"]]
let template = Template(templateString: templateString, environment: .init(trimBehaviour: .smart))
let result = try template.render(context)
// swiftlint:disable indentation_width
try expect(result) == """
- item 1
- item 2
text
"""
// swiftlint:enable indentation_width
}
func testSmartTrimCanRemoveNewlinesWhileKeepingWhitespace() throws {
// swiftlint:disable indentation_width
let templateString = """
Items:
{% for item in items %}
- {{item}}
{% endfor %}
"""
// swiftlint:enable indentation_width
let context = ["items": ["item 1", "item 2"]]
let template = Template(templateString: templateString, environment: .init(trimBehaviour: .smart))
let result = try template.render(context)
// swiftlint:disable indentation_width
try expect(result) == """
Items:
- item 1
- item 2
"""
// swiftlint:enable indentation_width
}
func testTrimSymbols() {
it("Respects whitespace control symbols in for tags") {
// swiftlint:disable indentation_width
let template: Template = """
{% for num in numbers -%}
{{num}}
{%- endfor %}
"""
// swiftlint:enable indentation_width
let result = try template.render([ "numbers": Array(1...9) ])
try expect(result) == "123456789"
}
it("Respects whitespace control symbols in if tags") {
let template: Template = """
{% if value -%}
{{text}}
{%- endif %}
"""
let result = try template.render([ "text": "hello", "value": true ])
try expect(result) == "hello"
}
}
func testTrimSymbolsOverridingEnvironment() {
let environment = Environment(trimBehaviour: .all)
it("respects whitespace control symbols in if tags") {
// swiftlint:disable indentation_width
let templateString = """
{% if value +%}
{{text}}
{%+ endif %}
"""
// swiftlint:enable indentation_width
let template = Template(templateString: templateString, environment: environment)
let result = try template.render([ "text": "hello", "value": true ])
try expect(result) == "\n hello\n"
}
it("can customize blocks on same line as text") {
// swiftlint:disable indentation_width
let templateString = """
Items:{% for item in items +%}
- {{item}}
{%- endfor %}
"""
// swiftlint:enable indentation_width
let context = ["items": ["item 1", "item 2"]]
let template = Template(templateString: templateString, environment: environment)
let result = try template.render(context)
// swiftlint:disable indentation_width
try expect(result) == """
Items:
- item 1
- item 2
"""
// swiftlint:enable indentation_width
}
}
}

View File

@@ -1,37 +1,15 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Spectre
@testable import Stencil
import XCTest
#if os(OSX)
@objc
class Superclass: NSObject {
@objc let name = "Foo"
}
@objc
class Object: Superclass {
@objc let title = "Hello World"
}
#endif
private struct Person {
let name: String
}
private struct Article {
let author: Person
}
private class WebSite {
let url: String = "blog.com"
}
private class Blog: WebSite {
let articles: [Article] = [Article(author: Person(name: "Kyle"))]
let featuring: Article? = Article(author: Person(name: "Jhon"))
}
final class VariableTests: XCTestCase {
let context: Context = {
private let context: Context = {
let ext = Extension()
ext.registerFilter("incr") { arg in
(arg.flatMap { toNumber(value: $0) } ?? 0) + 1
@@ -49,11 +27,15 @@ final class VariableTests: XCTestCase {
],
"article": Article(author: Person(name: "Kyle")),
"blog": Blog(),
"tuple": (one: 1, two: 2)
"tuple": (one: 1, two: 2),
"dynamic": [
"enum": DynamicEnum.someValue,
"struct": DynamicStruct()
]
], environment: environment)
#if os(OSX)
#if os(OSX)
context["object"] = Object()
#endif
#endif
return context
}()
@@ -64,12 +46,24 @@ final class VariableTests: XCTestCase {
try expect(result) == "name"
}
it("can resolve a string literal with one double quote") {
let variable = Variable("\"")
let result = try variable.resolve(self.context) as? String
try expect(result).to.beNil()
}
it("can resolve a string literal with single quotes") {
let variable = Variable("'name'")
let result = try variable.resolve(self.context) as? String
try expect(result) == "name"
}
it("can resolve a string literal with one single quote") {
let variable = Variable("'")
let result = try variable.resolve(self.context) as? String
try expect(result).to.beNil()
}
it("can resolve an integer literal") {
let variable = Variable("5")
let result = try variable.resolve(self.context) as? Int
@@ -146,6 +140,20 @@ final class VariableTests: XCTestCase {
}
}
func testDynamicMemberLookup() {
it("can resolve dynamic member lookup") {
let variable = Variable("dynamic.struct.test")
let result = try variable.resolve(self.context) as? String
try expect(result) == "this is a dynamic response"
}
it("can resolve dynamic enum rawValue") {
let variable = Variable("dynamic.enum.rawValue")
let result = try variable.resolve(self.context) as? String
try expect(result) == "this is raw value"
}
}
func testReflection() {
it("can resolve a property with reflection") {
let variable = Variable("article.author.name")
@@ -173,7 +181,7 @@ final class VariableTests: XCTestCase {
}
func testKVO() {
#if os(OSX)
#if os(OSX)
it("can resolve a value via KVO") {
let variable = Variable("object.title")
let result = try variable.resolve(self.context) as? String
@@ -191,7 +199,7 @@ final class VariableTests: XCTestCase {
let result = try variable.resolve(self.context) as? String
try expect(result).to.beNil()
}
#endif
#endif
}
func testTuple() {
@@ -244,7 +252,7 @@ final class VariableTests: XCTestCase {
}
}
#if os(OSX)
#if os(OSX)
it("can resolve a subscript via KVO") {
try self.context.push(dictionary: ["property": "name"]) {
let variable = Variable("object[property]")
@@ -252,7 +260,7 @@ final class VariableTests: XCTestCase {
try expect(result) == "Foo"
}
}
#endif
#endif
it("can resolve an optional subscript via reflection") {
try self.context.push(dictionary: ["property": "featuring"]) {
@@ -353,3 +361,44 @@ final class VariableTests: XCTestCase {
}
}
}
// MARK: - Helpers
#if os(OSX)
@objc
class Superclass: NSObject {
@objc let name = "Foo"
}
@objc
class Object: Superclass {
@objc let title = "Hello World"
}
#endif
private struct Person {
let name: String
}
private struct Article {
let author: Person
}
private class WebSite {
let url: String = "blog.com"
}
private class Blog: WebSite {
let articles: [Article] = [Article(author: Person(name: "Kyle"))]
let featuring: Article? = Article(author: Person(name: "Jhon"))
}
@dynamicMemberLookup
private struct DynamicStruct: DynamicMemberLookup {
subscript(dynamicMember member: String) -> Any? {
member == "test" ? "this is a dynamic response" : nil
}
}
private enum DynamicEnum: String, DynamicMemberLookup {
case someValue = "this is raw value"
}

View File

@@ -1,228 +0,0 @@
import XCTest
extension ContextTests {
static let __allTests = [
("testContextRestoration", testContextRestoration),
("testContextSubscripting", testContextSubscripting),
]
}
extension EnvironmentBaseAndChildTemplateTests {
static let __allTests = [
("testRuntimeErrorInBaseTemplate", testRuntimeErrorInBaseTemplate),
("testRuntimeErrorInChildTemplate", testRuntimeErrorInChildTemplate),
("testSyntaxErrorInBaseTemplate", testSyntaxErrorInBaseTemplate),
("testSyntaxErrorInChildTemplate", testSyntaxErrorInChildTemplate),
]
}
extension EnvironmentIncludeTemplateTests {
static let __allTests = [
("testRuntimeError", testRuntimeError),
("testSyntaxError", testSyntaxError),
]
}
extension EnvironmentTests {
static let __allTests = [
("testLoading", testLoading),
("testRendering", testRendering),
("testRenderingError", testRenderingError),
("testSyntaxError", testSyntaxError),
("testUnknownFilter", testUnknownFilter),
]
}
extension ExpressionsTests {
static let __allTests = [
("testAndExpression", testAndExpression),
("testEqualityExpression", testEqualityExpression),
("testExpressionParsing", testExpressionParsing),
("testFalseExpressions", testFalseExpressions),
("testFalseInExpression", testFalseInExpression),
("testInequalityExpression", testInequalityExpression),
("testLessThanEqualExpression", testLessThanEqualExpression),
("testLessThanExpression", testLessThanExpression),
("testMoreThanEqualExpression", testMoreThanEqualExpression),
("testMoreThanExpression", testMoreThanExpression),
("testMultipleExpressions", testMultipleExpressions),
("testNotExpression", testNotExpression),
("testOrExpression", testOrExpression),
("testTrueExpressions", testTrueExpressions),
("testTrueInExpression", testTrueInExpression),
]
}
extension FilterTagTests {
static let __allTests = [
("testFilterTag", testFilterTag),
]
}
extension FilterTests {
static let __allTests = [
("testDefaultFilter", testDefaultFilter),
("testDynamicFilters", testDynamicFilters),
("testFilterSuggestion", testFilterSuggestion),
("testIndentContent", testIndentContent),
("testIndentFirstLine", testIndentFirstLine),
("testIndentNotEmptyLines", testIndentNotEmptyLines),
("testIndentWithArbitraryCharacter", testIndentWithArbitraryCharacter),
("testJoinFilter", testJoinFilter),
("testRegistration", testRegistration),
("testRegistrationOverrideDefault", testRegistrationOverrideDefault),
("testRegistrationWithArguments", testRegistrationWithArguments),
("testSplitFilter", testSplitFilter),
("testStringFilters", testStringFilters),
("testStringFiltersWithArrays", testStringFiltersWithArrays),
]
}
extension ForNodeTests {
static let __allTests = [
("testArrayOfTuples", testArrayOfTuples),
("testForNode", testForNode),
("testHandleInvalidInput", testHandleInvalidInput),
("testIterateDictionary", testIterateDictionary),
("testIterateRange", testIterateRange),
("testIterateUsingMirroring", testIterateUsingMirroring),
("testLoopMetadata", testLoopMetadata),
("testWhereExpression", testWhereExpression),
]
}
extension IfNodeTests {
static let __allTests = [
("testEvaluatesNilAsFalse", testEvaluatesNilAsFalse),
("testParseIf", testParseIf),
("testParseIfnot", testParseIfnot),
("testParseIfWithElif", testParseIfWithElif),
("testParseIfWithElifWithoutElse", testParseIfWithElifWithoutElse),
("testParseIfWithElse", testParseIfWithElse),
("testParseMultipleElif", testParseMultipleElif),
("testParsingErrors", testParsingErrors),
("testRendering", testRendering),
("testSupportsRangeVariables", testSupportsRangeVariables),
("testSupportVariableFilters", testSupportVariableFilters),
]
}
extension IncludeTests {
static let __allTests = [
("testParsing", testParsing),
("testRendering", testRendering),
]
}
extension InheritanceTests {
static let __allTests = [
("testInheritance", testInheritance),
]
}
extension LexerTests {
static let __allTests = [
("testComment", testComment),
("testContentMixture", testContentMixture),
("testEmptyVariable", testEmptyVariable),
("testEscapeSequence", testEscapeSequence),
("testNewlines", testNewlines),
("testPerformance", testPerformance),
("testText", testText),
("testTokenizeIncorrectSyntaxWithoutCrashing", testTokenizeIncorrectSyntaxWithoutCrashing),
("testTokenWithoutSpaces", testTokenWithoutSpaces),
("testUnclosedBlock", testUnclosedBlock),
("testUnclosedTag", testUnclosedTag),
("testVariable", testVariable),
("testVariablesWithoutBeingGreedy", testVariablesWithoutBeingGreedy),
]
}
extension NodeTests {
static let __allTests = [
("testRendering", testRendering),
("testTextNode", testTextNode),
("testVariableNode", testVariableNode),
]
}
extension NowNodeTests {
static let __allTests = [
("testParsing", testParsing),
("testRendering", testRendering),
]
}
extension StencilTests {
static let __allTests = [
("testStencil", testStencil),
]
}
extension TemplateLoaderTests {
static let __allTests = [
("testDictionaryLoader", testDictionaryLoader),
("testFileSystemLoader", testFileSystemLoader),
]
}
extension TemplateTests {
static let __allTests = [
("testTemplate", testTemplate),
]
}
extension TokenParserTests {
static let __allTests = [
("testTokenParser", testTokenParser),
]
}
extension TokenTests {
static let __allTests = [
("testToken", testToken),
]
}
extension VariableTests {
static let __allTests = [
("testArray", testArray),
("testDictionary", testDictionary),
("testKVO", testKVO),
("testLiterals", testLiterals),
("testMultipleSubscripting", testMultipleSubscripting),
("testOptional", testOptional),
("testRangeVariable", testRangeVariable),
("testReflection", testReflection),
("testSubscripting", testSubscripting),
("testTuple", testTuple),
("testVariable", testVariable),
]
}
#if !os(macOS)
public func __allTests() -> [XCTestCaseEntry] {
return [
testCase(ContextTests.__allTests),
testCase(EnvironmentBaseAndChildTemplateTests.__allTests),
testCase(EnvironmentIncludeTemplateTests.__allTests),
testCase(EnvironmentTests.__allTests),
testCase(ExpressionsTests.__allTests),
testCase(FilterTagTests.__allTests),
testCase(FilterTests.__allTests),
testCase(ForNodeTests.__allTests),
testCase(IfNodeTests.__allTests),
testCase(IncludeTests.__allTests),
testCase(InheritanceTests.__allTests),
testCase(LexerTests.__allTests),
testCase(NodeTests.__allTests),
testCase(NowNodeTests.__allTests),
testCase(StencilTests.__allTests),
testCase(TemplateLoaderTests.__allTests),
testCase(TemplateTests.__allTests),
testCase(TokenParserTests.__allTests),
testCase(TokenTests.__allTests),
testCase(VariableTests.__allTests),
]
}
#endif

View File

@@ -0,0 +1,5 @@
{% block header %}Header{% endblock %}
{% block body %}Body{% endblock %}
Repeat
{{ block.header }}
{{ block.body }}

View File

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

View File

@@ -0,0 +1,2 @@
{% extends "if-block.html" %}
{% 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 %}

View File

@@ -0,0 +1 @@
{% block title %}Title{% endblock %}

View File

@@ -41,8 +41,7 @@ You can iterate over range literals created using ``N...M`` syntax, both in asce
{% endfor %}
</ul>
The ``for`` tag can contain optional ``where`` expression to filter out
elements on which this expression evaluates to false.
The ``for`` tag can contain optional ``where`` expression to filter out elements on which this expression evaluates to false.
.. code-block:: html+django
@@ -52,8 +51,7 @@ elements on which this expression evaluates to false.
{% endfor %}
</ul>
The ``for`` tag can take an optional ``{% empty %}`` block that will be
displayed if the given list is empty or could not be found.
The ``for`` tag can take an optional ``{% empty %}`` block that will be displayed if the given list is empty or could not be found.
.. code-block:: html+django
@@ -89,12 +87,74 @@ For example:
This is user number {{ forloop.counter }} user.
{% endfor %}
The ``for`` tag accepts an optional label, so that it may later be referred to by name. The contexts of parent labeled loops can be accessed via the `forloop` property:
.. code-block:: html+django
{% outer: for item in users %}
{% for item in 1..3 %}
{% if forloop.outer.first %}
This is the first user.
{% endif %}
{% endfor %}
{% endfor %}
``break``
~~~~~~~~~
The ``break`` tag lets you jump out of a for loop, for example if a certain condition is met:
.. code-block:: html+django
{% for user in users %}
{% if user.inaccessible %}
{% break %}
{% endif %}
This is user {{ user.name }}.
{% endfor %}
Break tags accept an optional label parameter, so that you may break out of multiple loops:
.. code-block:: html+django
{% outer: for user in users %}
{% for address in user.addresses %}
{% if address.isInvalid %}
{% break outer %}
{% endif %}
{% endfor %}
{% endfor %}
``continue``
~~~~~~~~~
The ``continue`` tag lets you skip the rest of the blocks in a loop, for example if a certain condition is met:
.. code-block:: html+django
{% for user in users %}
{% if user.inaccessible %}
{% continue %}
{% endif %}
This is user {{ user.name }}.
{% endfor %}
Continue tags accept an optional label parameter, so that you may skip the execution of multiple loops:
.. code-block:: html+django
{% outer: for user in users %}
{% for address in user.addresses %}
{% if address.isInvalid %}
{% continue outer %}
{% endif %}
{% endfor %}
{% endfor %}
``if``
~~~~~~
The ``{% if %}`` tag evaluates a variable, and if that variable evaluates to
true the contents of the block are processed. Being true is defined as:
The ``{% if %}`` tag evaluates a variable, and if that variable evaluates to true the contents of the block are processed. Being true is defined as:
* Present in the context
* Being non-empty (dictionaries or arrays)
@@ -115,8 +175,7 @@ true the contents of the block are processed. Being true is defined as:
Operators
^^^^^^^^^
``if`` tags may combine ``and``, ``or`` and ``not`` to test multiple variables
or to negate a variable.
``if`` tags may combine ``and``, ``or`` and ``not`` to test multiple variables or to negate a variable.
.. code-block:: html+django
@@ -279,8 +338,7 @@ By default the included file gets passed the current context. You can pass a sub
{% include "comment.html" comment %}
The `include` tag requires you to provide a loader which will be used to lookup
the template.
The `include` tag requires you to provide a loader which will be used to lookup the template.
.. code-block:: swift
@@ -301,8 +359,7 @@ See :ref:`template-inheritance` for more information.
``block``
~~~~~~~~~
Defines a block that can be overridden by child templates. See
:ref:`template-inheritance` for more information.
Defines a block that can be overridden by child templates. See :ref:`template-inheritance` for more information.
.. _built-in-filters:
@@ -312,8 +369,7 @@ Built-in Filters
``capitalize``
~~~~~~~~~~~~~~
The capitalize filter allows you to capitalize a string.
For example, `stencil` to `Stencil`. Can be applied to array of strings to change each string.
The capitalize filter allows you to capitalize a string. For example, `stencil` to `Stencil`. Can be applied to array of strings to change each string.
.. code-block:: html+django
@@ -322,8 +378,7 @@ For example, `stencil` to `Stencil`. Can be applied to array of strings to chang
``uppercase``
~~~~~~~~~~~~~
The uppercase filter allows you to transform a string to uppercase.
For example, `Stencil` to `STENCIL`. Can be applied to array of strings to change each string.
The uppercase filter allows you to transform a string to uppercase. For example, `Stencil` to `STENCIL`. Can be applied to array of strings to change each string.
.. code-block:: html+django
@@ -332,8 +387,7 @@ For example, `Stencil` to `STENCIL`. Can be applied to array of strings to chang
``lowercase``
~~~~~~~~~~~~~
The uppercase filter allows you to transform a string to lowercase.
For example, `Stencil` to `stencil`. Can be applied to array of strings to change each string.
The uppercase filter allows you to transform a string to lowercase. For example, `Stencil` to `stencil`. Can be applied to array of strings to change each string.
.. code-block:: html+django
@@ -342,8 +396,7 @@ For example, `Stencil` to `stencil`. Can be applied to array of strings to chang
``default``
~~~~~~~~~~~
If a variable not present in the context, use given default. Otherwise, use the
value of the variable. For example:
If a variable not present in the context, use given default. Otherwise, use the value of the variable. For example:
.. code-block:: html+django

View File

@@ -50,7 +50,7 @@ master_doc = 'index'
# General information about the project.
project = 'Stencil'
copyright = '2016, Kyle Fuller'
copyright = '2022, Kyle Fuller'
author = 'Kyle Fuller'
# The version info for the project you're documenting, acts as replacement for
@@ -58,9 +58,9 @@ author = 'Kyle Fuller'
# built documents.
#
# The short X.Y version.
version = '0.14.0'
version = '0.15.1'
# The full version, including alpha/beta/rc tags.
release = '0.14.0'
release = '0.15.1'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.

View File

@@ -14,7 +14,7 @@ dependencies inside ``Package.swift``.
let package = Package(
name: "MyApplication",
dependencies: [
.Package(url: "https://github.com/stencilproject/Stencil.git", majorVersion: 0, minor: 13),
.package(url: "https://github.com/stencilproject/Stencil.git", from: "0.15.1"),
]
)
@@ -26,7 +26,7 @@ If you're using CocoaPods, you can add Stencil to your ``Podfile`` and then run
.. code-block:: ruby
pod 'Stencil', '~> 0.14.0'
pod 'Stencil', '~> 0.15.1'
Carthage
--------
@@ -37,7 +37,7 @@ Carthage
.. code-block:: text
github "stencilproject/Stencil" ~> 0.14.0
github "stencilproject/Stencil" ~> 0.15.1
2) Checkout your dependencies, generate the Stencil Xcode project, and then use Carthage to build Stencil:

View File

@@ -22,6 +22,7 @@ following lookup:
- Dictionary lookup
- Array and string lookup (first, last, count, by index)
- Key value coding lookup
- @dynamicMemberLookup when conforming to our `DynamicMemberLookup` marker protocol
- Type introspection (via ``Mirror``)
For example, if `people` was an array:
@@ -49,6 +50,24 @@ For example, if you have the following context:
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.
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:
.. code-block:: swift
[
"magic": LazyValueWrapper(myHeavyCalculations())
]
Boolean expressions
-------------------
Boolean expressions can be rendered using ``{{ ... }}`` tag.
For example, this will output string `true` if variable is equal to 1 and `false` otherwise:
.. code-block:: html+django
{{ variable == 1 }}
Filters
~~~~~~~
@@ -94,6 +113,17 @@ To comment out part of your template, you can use the following syntax:
{# My comment is completely hidden #}
Whitespace Control
------------------
Stencil supports the same syntax as Jinja for whitespace control, see `their docs for more information <https://jinja.palletsprojects.com/en/3.1.x/templates/#whitespace-control>`_.
Essentially, Stencil will **not** trim whitespace by default. However you can:
- 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.
- You can disable this per-block using the `+` control character. For example `{{+ if … }}` to preserve whitespace before.
- You can force trimming per-block by using the `-` control character. For example `{{ if … -}}` to trim whitespace after.
.. _template-inheritance:
Template inheritance
@@ -129,7 +159,7 @@ Let's take a look at an example. Here is our base template (``base.html``):
</html>
This example declares three blocks, ``title``, ``sidebar`` and ``content``. We
can use the ``{% extends %}`` template tag to inherit from out base template
can use the ``{% extends %}`` template tag to inherit from our base template
and then use ``{% block %}`` to override any blocks from our base template.
A child template might look like the following:
@@ -183,3 +213,5 @@ inheritance is the following three-level approach:
extend ``base.html`` and include section-specific styles/design.
* Create individual templates for each type of page, such as a news article or
blog entry. These templates extend the appropriate section template.
You can render block's content more than once by using ``{{ block.name }}`` **after** a block is defined.

97
rakelib/Dangerfile Normal file
View File

@@ -0,0 +1,97 @@
# frozen_string_literal: true
require_relative 'check_changelog'
is_release = github.branch_for_head.start_with?('release/')
is_hotfix = github.branch_for_head.start_with?('hotfix/')
################################################
# Welcome message
markdown [
"Hey 👋 I'm Eve, the friendly bot watching over Stencil 🤖",
'Thanks a lot for your contribution!',
'', '---', ''
]
need_fixes = []
################################################
# Make it more obvious that a PR is a work in progress and shouldn't be merged yet
warn('PR is classed as Work in Progress') if github.pr_title.include? '[WIP]'
# Note when there is a big PR
message('Big PR') if git.lines_of_code > 500 && !is_release
################################################
# Check for correct base branch
if is_release
message('This is a Release PR')
require 'open3'
stdout, _, status = Open3.capture3('bundle', 'exec', 'rake', 'changelog:check')
markdown [
'',
'### ChangeLog check',
'',
stdout
]
need_fixes << fail('Please fix the CHANGELOG errors') unless status.success?
stdout, _, status = Open3.capture3('bundle', 'exec', 'rake', 'release:check_versions')
markdown [
'',
'### Release version check',
'',
stdout
]
need_fixes << fail('Please fix the versions inconsistencies') unless status.success?
elsif is_hotfix
message('This is a Hotfix PR')
end
################################################
# Check for a CHANGELOG entry
declared_trivial = github.pr_title.include? '#trivial'
has_changelog = git.modified_files.include?('CHANGELOG.md')
changelog_msg = ''
unless has_changelog || declared_trivial
repo_url = github.pr_json['head']['repo']['html_url']
pr_title = github.pr_title
pr_title += '.' unless pr_title.end_with?('.')
pr_number = github.pr_json['number']
pr_url = github.pr_json['html_url']
pr_author = github.pr_author
pr_author_url = "https://github.com/#{pr_author}"
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).")
changelog_msg = <<-CHANGELOG_FORMAT.gsub(/^ *\|/, '')
|📝 We use the following format for CHANGELOG entries:
|```
|* #{pr_title}
| [##{pr_number}](#{pr_url})
| [@#{pr_author}](#{pr_author_url})
|```
|:bulb: Don't forget to end the line describing your changes by a period and two spaces.
CHANGELOG_FORMAT
# changelog_msg is printed during the "Encouragement message" section, see below
end
changelog_warnings = check_changelog
unless changelog_warnings.empty?
need_fixes << warn('Found some warnings in CHANGELOG.md')
changelog_warnings.each do |warning|
warn(warning[:message], file: 'CHANGELOG.md', line: warning[:line])
end
end
################################################
# Encouragement message
if need_fixes.empty?
markdown('Seems like everything is in order 👍 You did a good job here! 🤝')
else
markdown('Once you fix those tiny nitpickings above, we should be good to go! 🙌')
markdown(changelog_msg) unless changelog_msg.empty?
markdown(' _I will update this comment as you add new commits_')
end

View File

@@ -1,34 +1,56 @@
NEW_CHANGELOG_SECTION = "## Master\n" + ['Breaking', 'Enhancements', 'Deprecations', 'Bug Fixes', 'Internal Changes'].map do |s|
<<~MARKDOWN
# frozen_string_literal: true
### #{s}
# Used constants:
# _none_
_None_
MARKDOWN
end.join
def changelog_first_section
content = []
section_count = 0
File.foreach(CHANGELOG_FILE) do |line|
section_count += 1 if line.start_with?('## ')
break if section_count > 1
content.append(line) if section_count == 1
end
content[1..].join
end
require_relative 'check_changelog'
namespace :changelog do
# rake changelog:reset
desc "Add a new empty section at the top of the changelog and git push it"
desc 'Add the empty CHANGELOG entries after a new release'
task :reset do
header "Reset CHANGELOG"
content = File.read(CHANGELOG_FILE)
new_content = NEW_CHANGELOG_SECTION + "\n" + content
File.write(CHANGELOG_FILE, new_content)
changelog = File.read('CHANGELOG.md')
abort('A Master entry already exists') if changelog =~ /^##\s*Master$/
changelog.sub!(/^##[^#]/, "#{header}\\0")
File.write('CHANGELOG.md', changelog)
end
sh("git", "add", CHANGELOG_FILE)
sh("git", "commit", "-m", "Reset CHANGELOG")
sh("git", "push")
def header
<<-HEADER.gsub(/^\s*\|/, '')
|## Master
|
|### Breaking
|
|_None_
|
|### Enhancements
|
|_None_
|
|### Deprecations
|
|_None_
|
|### Bug Fixes
|
|_None_
|
|### Internal Changes
|
|_None_
|
HEADER
end
desc 'Check if links to issues and PRs use matching numbers between text & link'
task :check do
warnings = check_changelog
if warnings.empty?
puts "\u{2705} All entries seems OK (end with period + 2 spaces, correct links)"
else
puts "\u{274C} Some warnings were found:\n" + Array(warnings.map do |warning|
" - Line #{warning[:line]}: #{warning[:message]}"
end).join("\n")
exit 1
end
end
end

View File

@@ -0,0 +1,61 @@
# frozen_string_literal: true
# This analyze the CHANGELOG.md file and report warnings on its content
#
# It checks:
# - if the description part of each entry ends with a period and two spaces
# - that all links to PRs & issues with format [#nn](repo_url/nn) are consistent
# (use the same number in the link title and URL)
#
# @return Array of Hashes with keys `:line` & `:message` for each element
#
def check_changelog
current_repo = File.basename(`git remote get-url origin`.chomp, '.git').freeze
slug_re = '([a-zA-Z]*/[a-zA-Z]*)'
links = %r{\[#{slug_re}?\#([0-9]+)\]\(https://github.com/#{slug_re}/(issues|pull)/([0-9]+)\)}
links_typos = %r{https://github.com/#{slug_re}/(issue|pulls)/([0-9]+)}
all_warnings = []
inside_entry = false
last_line_has_correct_ending = false
File.readlines('CHANGELOG.md').each_with_index do |line, idx|
line.chomp! # Remove \n the end, it's easier for checks below
was_inside_entry = inside_entry
just_started_new_entry = line.start_with?('* ')
inside_entry = true if just_started_new_entry
inside_entry = false if /^ \[.*\]\(.*\)$/ =~ line # link-only line
if was_inside_entry && !inside_entry && !last_line_has_correct_ending
# We just ended an entry's description by starting the links, but description didn't end with '. '
# Note: entry descriptions can be on multiple lines, hence the need to wait for the next line
# to not be inside an entry to be able to consider the previous line as the end of entry description.
all_warnings.concat [
{ line: idx, message: 'Line describing your entry should end with a period and 2 spaces.' }
]
end
# Store if current line has correct ending, for next iteration, so that if the next line isn't
# part of the entry description, we can check if previous line ends description correctly.
# Also, lines just linking to CHANGELOG to other repositories (StencilSwiftKit & Stencil mainly)
# should be considered as not needing the '. ' ending.
last_line_has_correct_ending = line.end_with?('. ') || line.end_with?('/CHANGELOG.md)')
# Now, check that links [#nn](.../nn) have matching numbers in link title & URL
wrong_links = line.scan(links).reject do |m|
slug = m[0] || "stencilproject/#{current_repo}"
(slug == m[2]) && (m[1] == m[4])
end
all_warnings.concat Array(wrong_links.map do |m|
link_text = "#{m[0]}##{m[1]}"
link_url = "#{m[2]}##{m[4]}"
{ line: idx + 1, message: "Link text is #{link_text} but links points to #{link_url}." }
end)
# Flag common typos in GitHub issue/PR URLs
typo_links = line.scan(links_typos)
all_warnings.concat Array(typo_links.map do |_|
{ 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).' }
end)
end
all_warnings
end

50
rakelib/lint.rake Normal file
View File

@@ -0,0 +1,50 @@
# frozen_string_literal: true
# Used constants:
# - BUILD_DIR
namespace :lint do
SWIFTLINT = 'rakelib/lint.sh'
SWIFTLINT_VERSION = '0.48.0'
task :install do |task|
next if check_version
if OS.mac?
url = "https://github.com/realm/SwiftLint/releases/download/#{SWIFTLINT_VERSION}/portable_swiftlint.zip"
else
url = "https://github.com/realm/SwiftLint/releases/download/#{SWIFTLINT_VERSION}/swiftlint_linux.zip"
end
tmppath = '/tmp/swiftlint.zip'
destination = "#{BUILD_DIR}/swiftlint"
Utils.run([
%(curl -Lo #{tmppath} #{url}),
%(rm -rf #{destination}),
%(mkdir -p #{destination}),
%(unzip #{tmppath} -d #{destination})
], task)
end
desc 'Lint the code'
task :code => :install do |task|
Utils.print_header 'Linting the code'
Utils.run(%(#{SWIFTLINT} sources), task)
end
desc 'Lint the tests'
task :tests => :install do |task|
Utils.print_header 'Linting the unit test code'
Utils.run(%(#{SWIFTLINT} tests), task)
end
def check_version
swiftlint = "#{BUILD_DIR}/swiftlint/swiftlint"
return false unless File.executable?(swiftlint)
current = `#{swiftlint} version`.chomp
required = SWIFTLINT_VERSION.chomp
current == required
end
end

35
rakelib/lint.sh Executable file
View File

@@ -0,0 +1,35 @@
#!/bin/bash
PROJECT_DIR="${PROJECT_DIR:-`cd "$(dirname $0)/..";pwd`}"
SWIFTLINT="${PROJECT_DIR}/.build/swiftlint/swiftlint"
CONFIG="${PROJECT_DIR}/.swiftlint.yml"
if [ $CI ]; then
REPORTER="--reporter github-actions-logging"
else
REPORTER=
fi
# possible paths
paths_sources="Sources/Stencil"
paths_tests="Tests/StencilTests"
# load selected group
if [ $# -gt 0 ]; then
key="$1"
else
echo "error: need group to lint."
exit 1
fi
selected_path=`eval echo '$'paths_$key`
if [ -z "$selected_path" ]; then
echo "error: need a valid group to lint."
exit 1
fi
SUB_CONFIG="${PROJECT_DIR}/${selected_path}/.swiftlint.yml"
if [ -f "$SUB_CONFIG" ]; then
"$SWIFTLINT" lint --strict --config "$SUB_CONFIG" $REPORTER "${PROJECT_DIR}/${selected_path}"
else
"$SWIFTLINT" lint --strict --config "$CONFIG" $REPORTER "${PROJECT_DIR}/${selected_path}"
fi

View File

@@ -1,21 +1,12 @@
require 'json'
# frozen_string_literal: true
def current_pod_version
JSON.parse(File.read(PODSPEC_FILE))['version']
end
# Used constants:
# - POD_NAME
namespace :pod do
# rake pod:lint
desc "Lint the podspec"
task :lint do
header "Linting podspec"
sh("pod", "lib", "lint", PODSPEC_FILE)
end
# rake pod:push
desc "Push the podspec to trunk"
task :push do
header "Pushing podspec to trunk"
sh("pod", "trunk", "push", PODSPEC_FILE)
desc 'Lint the Pod'
task :lint do |task|
Utils.print_header 'Linting the pod spec'
Utils.run(%(bundle exec pod lib lint "#{POD_NAME}.podspec.json" --quick), task)
end
end

View File

@@ -1,67 +1,97 @@
# frozen_string_literal: true
# Used constants:
# - BUILD_DIR
require 'json'
namespace :release do
desc 'Create a new release'
task :new => [:check_versions, :check_tag_and_ask_to_release, 'spm:test', :github, :cocoapods]
# rake release:new
desc "Ask for a version number and prepare a release PR for that version"
task :new do
info "Current version is: #{current_pod_version}"
print "What version do you want to release? "
new_version = STDIN.gets.chomp
desc 'Check if all versions from the podspecs and CHANGELOG match'
task :check_versions do
results = []
Rake::Task['release:start'].invoke(new_version)
end
Utils.table_header('Check', 'Status')
# rake release:start[version]
desc "Start a release by creating a PR with the required changes to bump the version"
task :start, [:version] => ['release:create_branch', 'release:update_files', 'pod:lint', 'release:push_branch', 'github:create_release_pr', 'github:pull_master']
# rake release:finish[version]
desc "Finish a release after the PR has been merged, by tagging master and pushing to trunk"
task :finish => ['github:pull_master', 'github:tag', 'pod:push', 'github:create_release', 'changelog:reset']
### Helper tasks ###
# rake release:create_branch[version]
task :create_branch, [:version] do |_, args|
branch = release_branch(args[:version])
header "Creating release branch"
sh("git", "checkout", "-b", branch)
end
# rake release:update_files[version]
task :update_files, [:version] do |_, args|
version = args[:version]
header "Updating files for version #{version}"
podspec = JSON.parse(File.read(PODSPEC_FILE))
podspec['version'] = version
podspec['source']['tag'] = version
File.write(PODSPEC_FILE, JSON.pretty_generate(podspec) + "\n")
replace(CHANGELOG_FILE, '## Master' => "\#\# #{version}")
replace("docs/conf.py",
/^version = .*/ => %Q(version = '#{version}'),
/^release = .*/ => %Q(release = '#{version}')
)
replace("docs/installation.rst",
/pod 'Stencil', '.*'/ => %Q(pod 'Stencil', '~> #{version}'),
/github "stencilproject\/Stencil" ~> .*/ => %Q(github "stencilproject/Stencil" ~> #{version})
# Check if bundler is installed first, as we'll need it for the cocoapods task (and we prefer to fail early)
`which bundler`
results << Utils.table_result(
$CHILD_STATUS.success?,
'Bundler installed',
'Install bundler using `gem install bundler` and run `bundle install` first.'
)
## Commit Changes
sh("git", "add", PODSPEC_FILE, CHANGELOG_FILE, "docs/*")
sh("git", "commit", "-m", "Version #{version}")
# Extract version from podspec
podspec = Utils::podspec(POD_NAME)
v = podspec['version']
Utils.table_info("#{POD_NAME}.podspec", v)
# Check podspec tag
podspec_tag = podspec['source']['tag']
results << Utils.table_result(podspec_tag == v, 'Podspec version & tag equal', 'Update the `tag` in podspec')
# Check docs config
docs_version = Utils.first_match_in_file('docs/conf.py', /version = '(.+)'/, 1)
docs_release = Utils.first_match_in_file('docs/conf.py', /release = '(.+)'/, 1)
results << Utils.table_result(docs_version == v,'Docs, version updated', 'Update the `version` in docs/conf.py')
results << Utils.table_result(docs_release == v, 'Docs, release updated', 'Update the `release` in docs/conf.py')
# Check docs installation
docs_package = Utils.first_match_in_file('docs/installation.rst', /\.package\(url: .+ from: "(.+)"/, 1)
docs_cocoapods = Utils.first_match_in_file('docs/installation.rst', /pod 'Stencil', '~> (.+)'/, 1)
docs_carthage = Utils.first_match_in_file('docs/installation.rst', /github ".+\/Stencil" ~> (.+)/, 1)
results << Utils.table_result(docs_package == v, 'Docs, package updated', 'Update the package version in docs/installation.rst')
results << Utils.table_result(docs_cocoapods == v, 'Docs, cocoapods updated', 'Update the cocoapods version in docs/installation.rst')
results << Utils.table_result(docs_carthage == v, 'Docs, carthage updated', 'Update the carthage version in docs/installation.rst')
# Check if entry present in CHANGELOG
changelog_entry = Utils.first_match_in_file('CHANGELOG.md', /^## #{Regexp.quote(v)}$/)
results << Utils.table_result(changelog_entry, 'CHANGELOG, Entry added', "Add an entry for #{v} in CHANGELOG.md")
changelog_has_stable = system("grep -qi '^## Master' CHANGELOG.md")
results << Utils.table_result(!changelog_has_stable, 'CHANGELOG, No master', 'Remove section for master branch in CHANGELOG')
exit 1 unless results.all?
end
# rake release:push_branch[version]
task :push_branch, [:version] do |_, args|
branch = release_branch(args[:version])
desc "Check tag and ask to release"
task :check_tag_and_ask_to_release do
results = []
podspec_version = Utils.podspec_version(POD_NAME)
header "Pushing #{branch} to origin"
sh("git", "push", "-u", "origin", branch)
tag_set = !`git ls-remote --tags . refs/tags/#{podspec_version}`.empty?
results << Utils.table_result(
tag_set,
'Tag pushed',
'Please create a tag and push it'
)
exit 1 unless results.all?
print "Release version #{podspec_version} [Y/n]? "
exit 2 unless STDIN.gets.chomp == 'Y'
end
desc "Create a new GitHub release"
task :github do
require 'octokit'
client = Utils.octokit_client
tag = Utils.top_changelog_version
body = Utils.top_changelog_entry
raise 'Must be a valid version' if tag == 'Master'
repo_name = File.basename(`git remote get-url origin`.chomp, '.git').freeze
puts "Pushing release notes for tag #{tag}"
client.create_release("stencilproject/#{repo_name}", tag, name: tag, body: body)
end
desc "pod trunk push #{POD_NAME} to CocoaPods"
task :cocoapods do
Utils.print_header 'Pushing pod to CocoaPods Trunk'
sh "bundle exec pod trunk push #{POD_NAME}.podspec.json"
end
end

18
rakelib/spm.rake Normal file
View File

@@ -0,0 +1,18 @@
# frozen_string_literal: true
# Used constants:
# _none_
namespace :spm do
desc 'Build using SPM'
task :build do |task|
Utils.print_header 'Compile using SPM'
Utils.run('swift build', task, xcrun: true)
end
desc 'Run SPM Unit Tests'
task :test => :build do |task|
Utils.print_header 'Run the unit tests using SPM'
Utils.run('swift test --parallel', task, xcrun: true)
end
end

View File

@@ -1,28 +1,266 @@
def colorize(string, *codes)
if `tput colors`.chomp.to_i >= 8
code = codes.join(';')
puts "\e[#{code}m" + string + "\e[0m"
# frozen_string_literal: true
# Used constants:
# - MIN_XCODE_VERSION
require 'json'
require 'open3'
require 'pathname'
# Utility functions to run Xcode commands, extract versionning info and logs messages
#
class Utils
COLUMN_WIDTHS = [45, 12].freeze
## [ Run commands ] #########################################################
# formatter types
# :xcpretty : through xcpretty and store in artifacts
# :raw : store in artifacts
# :to_string : run using backticks and return output
# run a command using xcrun and xcpretty if applicable
def self.run(command, task, subtask = '', xcrun: false, formatter: :raw)
commands = if xcrun and OS.mac?
Array(command).map { |cmd| "#{version_select} xcrun #{cmd}" }
else
puts string
Array(command)
end
case formatter
when :xcpretty then xcpretty(commands, task, subtask)
when :raw then plain(commands, task, subtask)
when :to_string then `#{commands.join(' && ')}`
else raise "Unknown formatter '#{formatter}'"
end
end
## [ Convenience Helpers ] ##################################################
def self.podspec(file)
JSON.parse(File.read("#{file}.podspec.json"))
end
def self.podspec_version(file)
podspec_as_json(file)['version']
end
def self.pod_trunk_last_version(pod)
require 'yaml'
stdout, _, _ = Open3.capture3('bundle', 'exec', 'pod', 'trunk', 'info', pod)
stdout.sub!("\n#{pod}\n", '')
last_version_line = YAML.safe_load(stdout).first['Versions'].last
/^[0-9.]*/.match(last_version_line)[0] # Just the 'x.y.z' part
end
def self.spm_own_version(dep)
dependencies = JSON.load(File.new('Package.resolved'))['object']['pins']
dependencies.find { |d| d['package'] == dep }['state']['version']
end
def self.spm_resolved_version(dep)
dependencies = JSON.load(File.new('Package.resolved'))['object']['pins']
dependencies.find { |d| d['package'] == dep }['state']['version']
end
def self.last_git_tag_version
`git describe --tags --abbrev=0`.strip
end
def self.octokit_client
token = ENV['DANGER_GITHUB_API_TOKEN']
token ||= File.exist?('.apitoken') && File.read('.apitoken')
token ||= File.exist?('../.apitoken') && File.read('../.apitoken')
Utils.print_error('No .apitoken file found') unless token
require 'octokit'
Octokit::Client.new(access_token: token)
end
def self.top_changelog_version(changelog_file = 'CHANGELOG.md')
header, _, _ = Open3.capture3('grep', '-m', '1', '^## ', changelog_file)
header.gsub('## ', '').strip
end
def self.top_changelog_entry(changelog_file = 'CHANGELOG.md')
tag = top_changelog_version
stdout, _, _ = Open3.capture3('sed', '-n', "/^## #{tag}$/,/^## /p", changelog_file)
stdout.gsub(/^## .*$/, '').strip
end
def self.first_match_in_file(file, regexp, index = 0)
File.foreach(file) do |line|
m = regexp.match(line)
return m[index] if m
end
end
## [ Print info/errors ] ####################################################
# print an info header
def self.print_header(str)
puts "== #{str.chomp} ==".format(:yellow, :bold)
end
# print an info message
def self.print_info(str)
puts str.chomp.format(:green)
end
# print an error message
def self.print_error(str)
puts str.chomp.format(:red)
end
# format an info message in a 2 column table
def self.table_header(col1, col2)
puts "| #{col1.ljust(COLUMN_WIDTHS[0])} | #{col2.ljust(COLUMN_WIDTHS[1])} |"
puts "| #{'-' * COLUMN_WIDTHS[0]} | #{'-' * COLUMN_WIDTHS[1]} |"
end
# format an info message in a 2 column table
def self.table_info(label, msg)
puts "| #{label.ljust(COLUMN_WIDTHS[0])} | 👉 #{msg.ljust(COLUMN_WIDTHS[1] - 4)} |"
end
# format a result message in a 2 column table
def self.table_result(result, label, error_msg)
if result
puts "| #{label.ljust(COLUMN_WIDTHS[0])} | #{'✅'.ljust(COLUMN_WIDTHS[1] - 1)} |"
else
puts "| #{label.ljust(COLUMN_WIDTHS[0])} | ❌ - #{error_msg.ljust(COLUMN_WIDTHS[1] - 6)} |"
end
result
end
## [ Private helper functions ] ##################################################
# run a command, pipe output through 'xcpretty' and store the output in CI artifacts
def self.xcpretty(cmd, task, subtask)
command = Array(cmd).join(' && \\' + "\n")
if ENV['CI']
Rake.sh %(set -o pipefail && (\\\n#{command} \\\n) | bundle exec xcpretty --color --report junit)
elsif system('which xcpretty > /dev/null')
Rake.sh %(set -o pipefail && (\\\n#{command} \\\n) | bundle exec xcpretty --color)
else
Rake.sh command
end
end
private_class_method :xcpretty
# run a command and store the output in CI artifacts
def self.plain(cmd, task, subtask)
command = Array(cmd).join(' && \\' + "\n")
if ENV['CI']
if OS.mac?
Rake.sh %(set -o pipefail && (#{command}))
else
# dash on linux doesn't support `set -o`
Rake.sh %(/bin/bash -eo pipefail -c "#{command}")
end
else
Rake.sh command
end
end
private_class_method :plain
# select the xcode version we want/support
def self.version_select
@version_select ||= compute_developer_dir(MIN_XCODE_VERSION)
end
private_class_method :version_select
# Return the "DEVELOPER_DIR=..." prefix to use in order to point to the best Xcode version
#
# @param [String|Float|Gem::Requirement] version_req
# The Xcode version requirement.
# - If it's a Float, it's converted to a "~> x.y" requirement
# - If it's a String, it's converted to a Gem::Requirement as is
# @note If you pass a String, be sure to use "~> " in the string unless you really want
# to point to an exact, very specific version
#
def self.compute_developer_dir(version_req)
version_req = Gem::Requirement.new("~> #{version_req}") if version_req.is_a?(Float)
version_req = Gem::Requirement.new(version_req) unless version_req.is_a?(Gem::Requirement)
# if current Xcode already fulfills min version don't force DEVELOPER_DIR=...
current_xcode_version = `xcodebuild -version`.split("\n").first.match(/[0-9.]+/).to_s
return '' if version_req.satisfied_by? Gem::Version.new(current_xcode_version)
supported_versions = all_xcode_versions.select { |app| version_req.satisfied_by?(app[:vers]) }
latest_supported_xcode = supported_versions.sort_by { |app| app[:vers] }.last
# Check if it's at least the right version
if latest_supported_xcode.nil?
raise "\n[!!!] Requires Xcode #{version_req}, but we were not able to find it. " \
"If it's already installed, either `xcode-select -s` to it, or update your Spotlight index " \
"with 'mdimport /Applications/Xcode*'\n\n"
end
%(DEVELOPER_DIR="#{latest_supported_xcode[:path]}/Contents/Developer")
end
private_class_method :compute_developer_dir
# @return [Array<Hash>] A list of { :vers => ... , :path => ... } hashes
# of all Xcodes found on the machine using Spotlight
def self.all_xcode_versions
xcodes = `mdfind "kMDItemCFBundleIdentifier = 'com.apple.dt.Xcode'"`.chomp.split("\n")
xcodes.map do |path|
{ vers: Gem::Version.new(`mdls -name kMDItemVersion -raw "#{path}"`), path: path }
end
end
private_class_method :all_xcode_versions
end
# OS detection
#
module OS
def OS.mac?
(/darwin/ =~ RUBY_PLATFORM) != nil
end
def OS.linux?
OS.unix? and not OS.mac?
end
end
def header(title)
puts colorize("==> #{title}...", 1, 32) # bold, green
end
# Colorization support for Strings
#
class String
# colorization
FORMATTING = {
# text styling
bold: 1,
faint: 2,
italic: 3,
underline: 4,
# foreground colors
black: 30,
red: 31,
green: 32,
yellow: 33,
blue: 34,
magenta: 35,
cyan: 36,
white: 37,
# background colors
bg_black: 40,
bg_red: 41,
bg_green: 42,
bg_yellow: 43,
bg_blue: 44,
bg_magenta: 45,
bg_cyan: 46,
bg_white: 47
}.freeze
def info(string)
puts colorize(string, 34) # blue
end
def release_branch(version)
"release/#{version}"
end
def replace(file, replacements)
content = File.read(file)
replacements.each do |match, replacement|
content.gsub!(match, replacement)
# only enable formatting if terminal supports it
if `tput colors`.chomp.to_i >= 8
def format(*styles)
styles.map { |s| "\e[#{FORMATTING[s]}m" }.join + self + "\e[0m"
end
else
def format(*_styles)
self
end
end
File.write(file, content)
end