Skip to content
This repository was archived by the owner on Nov 16, 2020. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions Sources/TemplateKit/AST/TemplateExtend.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/// Statically embeds another template.
public struct TemplateExtend: CustomStringConvertible {
/// The path of the template to extend.
public var path: String

/// The values to substitute for the extended template's import tags.
public var exports: [String:[TemplateSyntax]]

/// Creates a new `TemplateExtend`.
/// - parameters:
/// - path: The path of the template to extend.
public init(path: String, exports: [String:[TemplateSyntax]]) {
self.path = path
self.exports = exports
}

/// See `CustomStringConvertible`
public var description: String {
let exports = self.exports.map { "\($0.key): \($0.value)" }.joined(separator: ", ")
return "extend(\(path), [\(exports)])"
}
}
17 changes: 17 additions & 0 deletions Sources/TemplateKit/AST/TemplateImport.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/// Statically imports a value from an extension.
public struct TemplateImport: CustomStringConvertible {
/// The identifier of the extension's associated export.
public var identifier: String

/// Creates a new `TemplateImport`.
/// - parameters:
/// - identifier: The identifier of the extension's associated export.
public init(identifier: String) {
self.identifier = identifier
}

/// See `CustomStringConvertible`
public var description: String {
return "import(\(identifier))"
}
}
2 changes: 2 additions & 0 deletions Sources/TemplateKit/AST/TemplateSyntax.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ public struct TemplateSyntax: CustomStringConvertible {
case .expression(let expr): return "(\(expr))"
case .constant(let const): return "\(const)"
case .embed(let embed): return "embed\(embed.path)"
case .import(let i): return i.description
case .extend(let extend): return extend.description
case .conditional(let cond): return "cond(\(cond))"
case .iterator(let it): return "while\(it)"
case .custom: return "custom()"
Expand Down
8 changes: 8 additions & 0 deletions Sources/TemplateKit/AST/TemplateSyntaxType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ public indirect enum TemplateSyntaxType {

/// See `TemplateEmbed`.
case embed(TemplateEmbed)

/// See `TemplateImport`.
case `import`(TemplateImport)

/// See `TemplateExtend`.
case extend(TemplateExtend)

/// See `TemplateConditional`.
case conditional(TemplateConditional)
Expand Down Expand Up @@ -38,6 +44,8 @@ public indirect enum TemplateSyntaxType {
case .raw: return "raw"
case .tag: return "tag"
case .embed: return "embed"
case .import: return "import"
case .extend: return "extend"
case .conditional: return "conditional"
case .iterator: return "iterator"
case .custom: return "custom"
Expand Down
120 changes: 119 additions & 1 deletion Sources/TemplateKit/Pipeline/TemplateRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,124 @@ extension TemplateRenderer {
}
}

/// Traverses the AST, resolving extend and import statements and replacing them with their results.
public func resolveExtensions(_ ast: [TemplateSyntax]) throws -> [TemplateSyntax] {

// Recursively traverses the given node and its children.
func _traverse(_ node: TemplateSyntax, exports: [String:[TemplateSyntax]]) throws -> [TemplateSyntax] {
func traverse(_ node: TemplateSyntax) throws -> [TemplateSyntax] {
return try _traverse(node, exports: exports)
}

// Some nodes, such as TemplateExpression and TemplateTag, require exactly one
// TemplateSyntax as a parameter. Imports and extends can result in any number
// of nodes being produced, so this function properly handles those cases.
func traverseExpectingResult(_ node: TemplateSyntax) throws -> TemplateSyntax {
let value = try traverse(node)
guard !value.isEmpty else {
throw TemplateKitError(
identifier: "noResultAfterExtensionResolution",
reason: "Import or extend returned nothing where a result value was expected",
source: node.source
)
}

if value.count == 1 { return value.first! }
else {
return TemplateSyntax(
type: .constant(.interpolated(value)),
source: node.source
)
}
}

switch node.type {

// Resolve and susbtitute imports and extensions.
case .import(let i):
guard let export = exports[i.identifier] else {
throw TemplateKitError(
identifier: "noSuchExport",
reason: "No extension has declared an export named \(i.identifier)",
source: node.source
)
}
return export

case .extend(let extend):
let newExports = try extend.exports.mapValues { try $0.flatMap(traverse) }
let exports = exports.merging(newExports, uniquingKeysWith: { (_, new) in new })

let path = extend.path.hasSuffix(templateFileEnding) ? extend.path : extend.path + templateFileEnding
let absolutePath = path.hasPrefix("/") ? path : relativeDirectory + path

guard let data = FileManager.default.contents(atPath: absolutePath) else {
throw TemplateKitError(
identifier: "fileNotFound",
reason: "No file was found at path: \(absolutePath)",
source: node.source
)
}
let parsed = try parser.parse(scanner: TemplateByteScanner(data: data, file: absolutePath))
return try parsed.flatMap { try _traverse($0, exports: exports) }

// Recursively traverse children.
case .constant(let constant):
if case .interpolated(let nodes) = constant {
return try [TemplateSyntax(type: .constant(.interpolated(nodes.flatMap(traverse))), source: node.source)]
} else {
return [node]
}
case .tag(let tag):
return try [TemplateSyntax(
type: .tag(.init(
name: tag.name,
parameters: tag.parameters.map(traverseExpectingResult),
body: tag.body?.flatMap(traverse))),
source: node.source)
]
case .embed(let embed):
return try [TemplateSyntax(type: .embed(.init(path: traverseExpectingResult(embed.path))), source: node.source)]
case .conditional(let conditional):
func traverseConditional(_ conditional: TemplateConditional) throws -> TemplateConditional{
return try .init(
condition: traverseExpectingResult(conditional.condition),
body: conditional.body.flatMap(traverse),
next: conditional.next.map(traverseConditional)
)
}
return try [TemplateSyntax(type: .conditional(traverseConditional(conditional)), source: node.source)]
case .iterator(let iterator):
return try [TemplateSyntax(
type: .iterator(.init(
key: traverseExpectingResult(iterator.key),
data: traverseExpectingResult(iterator.data),
body: iterator.body.flatMap(traverse))
),
source: node.source)
]
case .expression(let expression):
let result: TemplateExpression
switch expression {
case .infix(let op, let left, let right):
result = try .infix(op: op, left: traverseExpectingResult(left), right: traverseExpectingResult(right))
case .prefix(let op, let right):
result = try .prefix(op: op, right: traverseExpectingResult(right))
case .postfix(let op, let left):
result = try .postfix(op: op, left: traverseExpectingResult(left))
}
return [TemplateSyntax(type: .expression(result), source: node.source)]

// These tags cannot have children.
case .raw, .identifier, .custom: return [node]
}
}

return try ast.flatMap { try _traverse($0, exports: [:]) }
}



// MARK: Private

/// Serializes an AST + Context
Expand All @@ -140,6 +258,6 @@ extension TemplateRenderer {
/// Parses data to AST.
private func _parse(_ template: Data, file: String) throws -> [TemplateSyntax] {
let scanner = TemplateByteScanner(data: template, file: file)
return try parser.parse(scanner: scanner)
return try resolveExtensions(parser.parse(scanner: scanner))
}
}
9 changes: 9 additions & 0 deletions Sources/TemplateKit/Pipeline/TemplateSerializer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,15 @@ public final class TemplateSerializer {
case .embed(let embed): return try render(embed: embed, source: syntax.source)
case .iterator(let it): return try render(iterator: it, source: syntax.source)
case .custom(let cust): return cust.render(self)

case .import, .extend:
// Error cases: imports and extensions should have been statically resolved by `TemplateRenderer.resolveExtensions`.
throw TemplateKitError(
identifier: "unresolvedImportOrExtend",
reason: "Encountered an import or extend statement during serialization; " +
"has TemplateRenderer.resolveExtensions been called?",
source: syntax.source
)
}
}
}
1 change: 1 addition & 0 deletions Tests/LinuxMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ import XCTest

XCTMain([
testCase(TemplateDataEncoderTests.allTests),
testCase(ExtensionTests.allTests)
])
159 changes: 159 additions & 0 deletions Tests/TemplateKitTests/ExtensionTests/ExtensionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import TemplateKit
import XCTest

class ExtensionTests: XCTestCase {
class TestRenderer: TemplateRenderer {
var tags: [String : TagRenderer] = defaultTags

var asts: [String:[TemplateSyntax]] = [:]

// Returns a hardcoded AST, using the filename as a selector.
struct TestParser: TemplateParser {
weak var renderer: TestRenderer!
init(renderer: TestRenderer) { self.renderer = renderer }

func parse(scanner: TemplateByteScanner) throws -> [TemplateSyntax] {
return renderer.asts[URL(fileURLWithPath: scanner.file).lastPathComponent]!
}
}
lazy var parser: TemplateParser = TestParser(renderer: self)

var astCache: ASTCache? = nil

var templateFileEnding: String = ""
var relativeDirectory: String = URL(fileURLWithPath: #file).deletingLastPathComponent().path + "/"

var container: Container
init(container: Container) {
self.container = container
}
}

let renderer = TestRenderer(
container: BasicContainer(config: .init(), environment: .testing, services: .init(), on: EmbeddedEventLoop())
)

func testSimpleExtensionResolution() throws {
renderer.asts = [
"extension": [
.raw("Before"),
.extend("extended"),
.raw("After")
],
"extended": [.raw("Extension") ]
]
print(try renderer.resolveExtensions(renderer.asts["extension"]!))
XCTAssertEqual(try renderer.testRender("extension"), "BeforeExtensionAfter")
}

func testImports() throws {
renderer.asts = [
"extension": [
.raw("Before"),
.extend("extended", exports: [
"export1": [.raw("Export1")],
"export2": [.raw("Export2")],
"export3": [.integer(5)],
]),
.raw("After")
],
"extended": [
.raw("Extension"),
.import("export1"),
.init(
type: .conditional(.init(
condition: TemplateSyntax(
type: .expression(.infix(op: .lessThan, left: .integer(1), right: .import("export3"))),
source: TemplateSyntax.testSource
),
body: [.import("export2")])),
source: TemplateSyntax.testSource
)
]
]

print(try renderer.resolveExtensions(renderer.asts["extension"]!))
XCTAssertEqual(try renderer.testRender("extension"), "BeforeExtensionExport1Export2After")
}

func testNestedExtensions() throws {
//tanner0101's nested extension example from https://forums.swift.org/t/pitch-leaf-view-extensions/18194
renderer.asts = [
"extended": [
.raw("<html><head><title>"),
.import("title"),
.raw("</title></head><body>"),
.import("body"),
.raw("</body></html>")
],
"alert": [
.raw("<alert style="),
.import("class"),
.raw("><p>"),
.import("message"),
.raw("</p></alert>")
],
"extension": [
.extend("extended", exports: [
"title": [.raw("Welcome")],
"body": [
.extend("alert", exports: [
"class": [.raw("warning")],
"message": [.identifier("alert", "message")],
]),
.raw("Hello, "), .tag(name: "", parameters: [.identifier("name")]), .raw("!")
]
])
],
]

print(try renderer.resolveExtensions(renderer.asts["extension"]!))
let context = TemplateData.dictionary(["name": .string("Vapor"), "alert": .dictionary(["message": .string("Test")])])
XCTAssertEqual(
try renderer.testRender("extension", context),
"<html><head><title>Welcome</title></head><body><alert style=warning>" +
"<p>Test</p></alert>Hello, Vapor!</body></html>"
)
}

static var allTests = [
("testSimpleExtensionResolution", testSimpleExtensionResolution),
("testImports", testImports),
("testNestedExtensions", testNestedExtensions)
]
}

extension TemplateSyntax {
static let testSource = TemplateSource(file: "", line: 0, column: 0, range: 0..<0)

static func integer(_ value: Int) -> TemplateSyntax {
return .init(type: .constant(.int(value)), source: testSource)
}

static func identifier(_ names: String...) -> TemplateSyntax {
return .init(type: .identifier(.init(path: names.map(BasicKey.init))), source: testSource)
}

static func tag(name: String, parameters: [TemplateSyntax], body: [TemplateSyntax]? = nil) -> TemplateSyntax {
return .init(type: .tag(.init(name: name, parameters: parameters, body: body)), source: testSource)
}

static func raw(_ text: String) -> TemplateSyntax {
return .init(type: .raw(.init(data: text.data(using: .utf8)!)), source: testSource)
}

static func extend(_ path: String, exports: [String:[TemplateSyntax]] = [:]) -> TemplateSyntax {
return .init(type: .extend(.init(path: path, exports: exports)), source: testSource)
}

static func `import`(_ identifier: String) -> TemplateSyntax {
return .init(type: .import(.init(identifier: identifier)), source: testSource)
}
}

extension TemplateRenderer {
func testRender(_ path: String, _ context: TemplateData = .null) throws -> String {
let view = try self.render(path, context).wait()
return String(data: view.data, encoding: .utf8)!
}
}
1 change: 1 addition & 0 deletions Tests/TemplateKitTests/ExtensionTests/alert
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The contents of this file are ignored, since TestParser returns a hardcoded AST.
1 change: 1 addition & 0 deletions Tests/TemplateKitTests/ExtensionTests/extended
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The contents of this file are ignored, since TestParser returns a hardcoded AST.
1 change: 1 addition & 0 deletions Tests/TemplateKitTests/ExtensionTests/extension
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The contents of this file are ignored, since TestParser returns a hardcoded AST.