diff --git a/CHANGELOG.md b/CHANGELOG.md index d7cc8f38..9871788f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/Stencil/Environment.swift b/Sources/Stencil/Environment.swift index 97c9cc15..e758de3d 100644 --- a/Sources/Stencil/Environment.swift +++ b/Sources/Stencil/Environment.swift @@ -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 diff --git a/Sources/Stencil/Lexer.swift b/Sources/Stencil/Lexer.swift index 28b4e5e0..a017f04e 100644 --- a/Sources/Stencil/Lexer.swift +++ b/Sources/Stencil/Lexer.swift @@ -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) -> 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) } diff --git a/Sources/Stencil/Node.swift b/Sources/Stencil/Node.swift index bd16083c..c9205fa4 100644 --- a/Sources/Stencil/Node.swift +++ b/Sources/Stencil/Node.swift @@ -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(.. 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 { diff --git a/Sources/Stencil/Tokenizer.swift b/Sources/Stencil/Tokenizer.swift index 03c1afe8..ac4c7f3f 100644 --- a/Sources/Stencil/Tokenizer.swift +++ b/Sources/Stencil/Tokenizer.swift @@ -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 { diff --git a/Sources/Stencil/TrimBehaviour.swift b/Sources/Stencil/TrimBehaviour.swift new file mode 100644 index 00000000..05643a2b --- /dev/null +++ b/Sources/Stencil/TrimBehaviour.swift @@ -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]*$") +} diff --git a/Tests/StencilTests/LexerSpec.swift b/Tests/StencilTests/LexerSpec.swift index 588ad186..1c168f38 100644 --- a/Tests/StencilTests/LexerSpec.swift +++ b/Tests/StencilTests/LexerSpec.swift @@ -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 { diff --git a/Tests/StencilTests/NodeSpec.swift b/Tests/StencilTests/NodeSpec.swift index 9814494c..36f45b2b 100644 --- a/Tests/StencilTests/NodeSpec.swift +++ b/Tests/StencilTests/NodeSpec.swift @@ -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() { diff --git a/Tests/StencilTests/ParserSpec.swift b/Tests/StencilTests/ParserSpec.swift index cd8b12fb..44d79d94 100644 --- a/Tests/StencilTests/ParserSpec.swift +++ b/Tests/StencilTests/ParserSpec.swift @@ -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" + } - it("can parse a variable token") { - let parser = TokenParser(tokens: [ - .variable(value: "'name'", at: .unknown) - ], environment: Environment()) + 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" - } + 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()) - it("can parse a comment token") { - let parser = TokenParser(tokens: [ - .comment(value: "Secret stuff!", at: .unknown) - ], environment: Environment()) + let nodes = try parser.parse() + try expect(nodes.count) == 0 + } - let nodes = try parser.parse() - try expect(nodes.count) == 0 + func testTagToken() throws { + let simpleExtension = Extension() + simpleExtension.registerSimpleTag("known") { _ in + "" } - it("can parse a tag token") { - 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) - ], environment: Environment(extensions: [simpleExtension])) + let nodes = try parser.parse() + try expect(nodes.count) == 1 + } - let nodes = try parser.parse() - try expect(nodes.count) == 1 - } + 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 + )) + } - it("errors when parsing an unknown tag") { - let tokens: [Token] = [.block(value: "unknown", at: .unknown)] - let parser = TokenParser(tokens: tokens, environment: Environment()) + func testTransformWhitespaceBehaviourToTrimBehaviour() throws { + let simpleExtension = Extension() + simpleExtension.registerSimpleTag("known") { _ in "" } - try expect(try parser.parse()).toThrow(TemplateSyntaxError( - reason: "Unknown template tag 'unknown'", - token: tokens.first - )) - } + 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) } } diff --git a/Tests/StencilTests/TrimBehaviourSpec.swift b/Tests/StencilTests/TrimBehaviourSpec.swift new file mode 100644 index 00000000..6c674925 --- /dev/null +++ b/Tests/StencilTests/TrimBehaviourSpec.swift @@ -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 + } + } +} diff --git a/docs/templates.rst b/docs/templates.rst index 47083287..d3256350 100644 --- a/docs/templates.rst +++ b/docs/templates.rst @@ -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 --------------------