Merge pull request #287 from stencilproject/trim_whitespace

Add whitespace control mechanisms
This commit is contained in:
David Jennes
2022-07-28 18:25:45 +02:00
committed by GitHub
12 changed files with 463 additions and 74 deletions

View File

@@ -18,6 +18,14 @@
[Ilya Puchka](https://github.com/ilyapuchka)
[#219](https://github.com/stencilproject/Stencil/issues/219)
[#246](https://github.com/stencilproject/Stencil/pull/246)
- 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)
### Deprecations

View File

@@ -4,6 +4,8 @@ public struct Environment {
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?
@@ -13,14 +15,17 @@ public struct Environment {
/// - 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

View File

@@ -11,6 +11,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,6 +22,12 @@ 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
@@ -30,6 +39,16 @@ struct Lexer {
}
}
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 +59,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 +70,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 +84,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)
}

View File

@@ -41,14 +41,27 @@ public class SimpleNode: NodeType {
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 {
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
}
}

View File

@@ -19,6 +19,7 @@ public class TokenParser {
fileprivate var tokens: [Token]
fileprivate let environment: Environment
fileprivate var previousWhiteSpace: WhitespaceBehaviour.Behaviour?
/// Simple initializer
public init(tokens: [Token], environment: Environment) {
@@ -41,10 +42,12 @@ 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
@@ -60,6 +63,7 @@ public class TokenParser {
}
}
case .comment:
previousWhiteSpace = nil
continue
}
}
@@ -76,6 +80,10 @@ public class TokenParser {
return nil
}
func peekWhitespace() -> WhitespaceBehaviour.Behaviour? {
tokens.first?.whitespace?.leading
}
/// Insert a token
public func prependToken(_ token: Token) {
tokens.insert(token, at: 0)
@@ -95,6 +103,27 @@ public class TokenParser {
public func compileResolvable(_ token: String, containedIn containingToken: Token) throws -> Resolvable {
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
}
}
extension Environment {

View File

@@ -79,6 +79,19 @@ public struct SourceMap: Equatable {
}
}
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,14 +107,16 @@ 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.
@@ -120,8 +135,12 @@ public class Token: Equatable {
}
/// A token representing a template block.
public static func block(value: String, at sourceMap: SourceMap) -> Token {
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 {

View File

@@ -0,0 +1,70 @@
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

@@ -51,9 +51,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 +62,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 {
@@ -98,11 +98,26 @@ final class LexerTests: XCTestCase {
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 {
@@ -111,11 +126,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

@@ -14,6 +14,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() {

View File

@@ -3,62 +3,77 @@ import Spectre
import XCTest
final class TokenParserTests: XCTestCase {
func testTokenParser() {
it("can parse a text token") {
let parser = TokenParser(tokens: [
.text(value: "Hello World", at: .unknown)
], environment: Environment())
func testTextToken() throws {
let parser = TokenParser(tokens: [
.text(value: "Hello World", at: .unknown)
], environment: Environment())
let nodes = try parser.parse()
let node = nodes.first as? TextNode
let nodes = try parser.parse()
let node = nodes.first as? TextNode
try expect(nodes.count) == 1
try expect(node?.text) == "Hello World"
try expect(nodes.count) == 1
try expect(node?.text) == "Hello World"
}
func testVariableToken() throws {
let parser = TokenParser(tokens: [
.variable(value: "'name'", at: .unknown)
], environment: Environment())
let nodes = try parser.parse()
let node = nodes.first as? VariableNode
try expect(nodes.count) == 1
let result = try node?.render(Context())
try expect(result) == "name"
}
func testCommentToken() throws {
let parser = TokenParser(tokens: [
.comment(value: "Secret stuff!", at: .unknown)
], environment: Environment())
let nodes = try parser.parse()
try expect(nodes.count) == 0
}
func testTagToken() throws {
let simpleExtension = Extension()
simpleExtension.registerSimpleTag("known") { _ in
""
}
it("can parse a variable token") {
let parser = TokenParser(tokens: [
.variable(value: "'name'", at: .unknown)
], environment: Environment())
let parser = TokenParser(tokens: [
.block(value: "known", at: .unknown)
], environment: Environment(extensions: [simpleExtension]))
let nodes = try parser.parse()
let node = nodes.first as? VariableNode
try expect(nodes.count) == 1
let result = try node?.render(Context())
try expect(result) == "name"
}
let nodes = try parser.parse()
try expect(nodes.count) == 1
}
it("can parse a comment token") {
let parser = TokenParser(tokens: [
.comment(value: "Secret stuff!", at: .unknown)
], environment: Environment())
func testErrorUnknownTag() throws {
let tokens: [Token] = [.block(value: "unknown", at: .unknown)]
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
try expect(nodes.count) == 0
}
try expect(try parser.parse()).toThrow(TemplateSyntaxError(
reason: "Unknown template tag 'unknown'",
token: tokens.first
))
}
it("can parse a tag token") {
let simpleExtension = Extension()
simpleExtension.registerSimpleTag("known") { _ in
""
}
func testTransformWhitespaceBehaviourToTrimBehaviour() throws {
let simpleExtension = Extension()
simpleExtension.registerSimpleTag("known") { _ in "" }
let parser = TokenParser(tokens: [
.block(value: "known", at: .unknown)
], environment: Environment(extensions: [simpleExtension]))
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) == 1
}
it("errors when parsing an unknown tag") {
let tokens: [Token] = [.block(value: "unknown", at: .unknown)]
let parser = TokenParser(tokens: tokens, environment: Environment())
try expect(try parser.parse()).toThrow(TemplateSyntaxError(
reason: "Unknown template tag 'unknown'",
token: tokens.first
))
}
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

@@ -0,0 +1,137 @@
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

@@ -97,6 +97,17 @@ To comment out part of your template, you can use the following syntax:
.. _template-inheritance:
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
--------------------