diff --git a/Sources/Template.swift b/Sources/Template.swift index a590132..487dc4c 100644 --- a/Sources/Template.swift +++ b/Sources/Template.swift @@ -82,7 +82,29 @@ final public class Template { let templateAST = try repository.templateAST(named: templateName) self.init(repository: repository, templateAST: templateAST, baseContext: repository.configuration.baseContext) } - + + /// Creates a template from the contents of a URL. + /// + /// Eventual partial tags in the template refer to sibling templates using + /// the same extension. + /// + /// // `{{>partial}}` in `file://path/to/template.txt` loads `file://path/to/partial.txt`: + /// let template = try! Template(URL: "file://path/to/template.txt") + /// + /// - parameter URL: The URL of the template. + /// - parameter encoding: The encoding of the template resource. + /// - parameter configuration: The configuration for rendering. If the configuration is not specified, `Configuration.default` is used. + /// - throws: MustacheError + @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) + public convenience init(URL: Foundation.URL, encoding: String.Encoding = .utf8, configuration: Configuration = .default) async throws { + let baseURL = URL.deletingLastPathComponent() + let templateExtension = URL.pathExtension + let templateName = (URL.lastPathComponent as NSString).deletingPathExtension + let repository = TemplateRepository(baseURL: baseURL, templateExtension: templateExtension, encoding: encoding, configuration: configuration) + let templateAST = try await repository.templateAST(named: templateName) + self.init(repository: repository, templateAST: templateAST, baseContext: repository.configuration.baseContext) + } + /// Creates a template from a bundle resource. /// /// Eventual partial tags in the template refer to template resources using diff --git a/Sources/TemplateRepository.swift b/Sources/TemplateRepository.swift index 1c2a093..ca62355 100644 --- a/Sources/TemplateRepository.swift +++ b/Sources/TemplateRepository.swift @@ -344,7 +344,45 @@ final public class TemplateRepository { throw error } } - + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) + func templateAST(named name: String, relativeToTemplateID baseTemplateID: TemplateID? = nil) async throws -> TemplateAST { + guard let dataSource = self.dataSource else { + throw MustacheError(kind: .templateNotFound, message: "Missing dataSource", templateID: baseTemplateID) + } + + guard let templateID = dataSource.templateIDForName(name, relativeToTemplateID: baseTemplateID) else { + if let baseTemplateID = baseTemplateID { + throw MustacheError(kind: .templateNotFound, message: "Template not found: \"\(name)\" from \(baseTemplateID)", templateID: baseTemplateID) + } else { + throw MustacheError(kind: .templateNotFound, message: "Template not found: \"\(name)\"") + } + } + + if let templateAST = templateASTCache[templateID] { + // Return cached AST + return templateAST + } + + let templateString = try await dataSource.templateStringForTemplateID(templateID) + + // Cache an empty AST for that name so that we support recursive + // partials. + let templateAST = TemplateAST() + templateASTCache[templateID] = templateAST + + do { + let compiledAST = try self.templateAST(string: templateString, templateID: templateID) + // Success: update the empty AST + templateAST.updateFromTemplateAST(compiledAST) + return templateAST + } catch { + // Failure: remove the empty AST + templateASTCache.removeValue(forKey: templateID) + throw error + } + } + func templateAST(string: String, templateID: TemplateID? = nil) throws -> TemplateAST { // A Compiler let compiler = TemplateCompiler( @@ -463,6 +501,13 @@ final public class TemplateRepository { func templateStringForTemplateID(_ templateID: TemplateID) throws -> String { return try NSString(contentsOf: URL(string: templateID)!, encoding: encoding.rawValue) as String } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) + func templateStringForTemplateID(_ templateID: TemplateID) async throws -> String { + let (data, _) = try await URLSession.shared.data(from: URL(string: templateID)!) + + return (NSString(data: data, encoding: encoding.rawValue) ?? "") as String + } } diff --git a/Tests/Public/TemplateTests/TemplateFromMethodsTests/TemplateFromMethodsTests.swift b/Tests/Public/TemplateTests/TemplateFromMethodsTests/TemplateFromMethodsTests.swift index 5660d2b..3bb7fd3 100644 --- a/Tests/Public/TemplateTests/TemplateFromMethodsTests/TemplateFromMethodsTests.swift +++ b/Tests/Public/TemplateTests/TemplateFromMethodsTests/TemplateFromMethodsTests.swift @@ -274,3 +274,61 @@ class TemplateFromMethodsTests: XCTestCase { } } } + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) +extension TemplateFromMethodsTests { + func testTemplateFromURL() async { + let template = try! await Template(URL: templateURL) + let keyedSubscript = makeKeyedSubscriptFunction("foo") + let rendering = try! template.render(MustacheBox(keyedSubscript: keyedSubscript)) + XCTAssertEqual(valueForStringPropertyInRendering(rendering)!, "foo") + } + + func testParserErrorFromURL() async { + do { + let _ = try await Template(URL: parserErrorTemplateURL) + XCTFail("Expected MustacheError") + } catch let error as MustacheError { + XCTAssertEqual(error.kind, MustacheError.Kind.parseError) + XCTAssertTrue(error.description.range(of: "line 2") != nil) + XCTAssertTrue(error.description.range(of: parserErrorTemplatePath) != nil) + } catch { + XCTFail("Expected MustacheError") + } + + do { + let _ = try await Template(URL: parserErrorTemplateWrapperURL) + XCTFail("Expected MustacheError") + } catch let error as MustacheError { + XCTAssertEqual(error.kind, MustacheError.Kind.parseError) + XCTAssertTrue(error.description.range(of: "line 2") != nil) + XCTAssertTrue(error.description.range(of: parserErrorTemplatePath) != nil) + } catch { + XCTFail("Expected MustacheError") + } + } + + func testCompilerErrorFromURL() async { + do { + let _ = try await Template(URL: compilerErrorTemplateURL) + XCTFail("Expected MustacheError") + } catch let error as MustacheError { + XCTAssertEqual(error.kind, MustacheError.Kind.parseError) + XCTAssertTrue(error.description.range(of: "line 2") != nil) + XCTAssertTrue(error.description.range(of: compilerErrorTemplatePath) != nil) + } catch { + XCTFail("Expected MustacheError") + } + + do { + let _ = try await Template(URL: compilerErrorTemplateWrapperURL) + XCTFail("Expected MustacheError") + } catch let error as MustacheError { + XCTAssertEqual(error.kind, MustacheError.Kind.parseError) + XCTAssertTrue(error.description.range(of: "line 2") != nil) + XCTAssertTrue(error.description.range(of: compilerErrorTemplatePath) != nil) + } catch { + XCTFail("Expected MustacheError") + } + } +}