diff --git a/doc/templates.md b/doc/templates.md new file mode 100644 index 0000000..4dc8332 --- /dev/null +++ b/doc/templates.md @@ -0,0 +1,156 @@ +# Template Engine + +ringhttp includes a compile-time template engine. Templates are `.lt` files compiled to LitaC source before the main build runs, so there is no runtime parsing overhead. + +## Template Syntax + +Templates use JSP-style tags: + +| Tag | Purpose | +|-----|---------| +| `<% code %>` | Emit raw LitaC code (statements, variable declarations, etc.) | +| `<%= expr %>` | Append `expr` to the response body (calls `out.appendString(expr)`) | + +### Directives (inside `<% %>`) + +| Directive | Example | Effect | +|-----------|---------|--------| +| `@type "module" :: TypeName` | `@type "models/user" :: User` | Declares the input type. Generates a typed function signature (`input: *TypeName`) and adds the module import. | +| `@import "module/path"` | `@import "models/tag"` | Adds a LitaC import to the generated file. | +| `@load "file.lt"` | `@load "templates/header.html.lt"` | Calls the partial's `_WriteTemplate` function, passing `input` unchanged. | +| `@load "file.lt" field` | `@load "templates/card.html.lt" input.card` | Calls the partial passing a sub-field as input. | + +### Input type + +Without `@type`, the generated function signature is: + +```litac +public func page_html_lt_WriteTemplate(resp: *HttpResponse, input: *void) : Status +``` + +You cast manually inside the template body: + +```html +<% var data = input as (*PageData) %> +

<%= data.title %>

+``` + +With `@type`, the signature is typed and no cast is needed: + +```html +<% @type "models/user" :: User %> +

Hello, <%= input.name %>

+``` + +## Generated Output + +The generator produces one file per template: + +``` +src/gen/tmpl_page_html_lt.lita — generated function for page.html.lt +src/gen/tmpl_greeting_html_lt.lita — generated function for greeting.html.lt +src/gen/templates.lita — dispatch + named helpers on HttpResponse +``` + +`templates.lita` provides two call styles: + +```litac +// String dispatch (runtime name lookup, input is *void) +resp.template("page_html_lt", &pageData) + +// Typed named helper (compile-time type checked for @type templates) +resp.tmpl_greeting_html_lt(&user) +``` + +## Setting Up Template Generation in Your Project + +ringhttp is a library — template generation runs in your app's `pre_main.lita` via the LitaC `pre_build_command` feature. + +### 1. Create `src/pre_main.lita` + +```litac +import "std/libc" +import "std/mem" +import "std/string" + +import "ring/template" +import "ring/http_common" + +func main(len: i32, args: **char) : i32 { + var templateInputDir = "static/templates" + if(len > 1) { + templateInputDir = args[1] + } + + var genOutputDir = "src/gen" + if(len > 2) { + genOutputDir = args[2] + } + + printf("Generating templates from '%s' -> '%s'\n", templateInputDir, genOutputDir) + + var status = GenerateTemplates( + templateInputDir.toString(), + genOutputDir, + defaultAllocator + ) + + if(status != Status.OK) { + printf("Template generation failed: %s\n", StatusAsStr(status)) + return 1 + } + + return 0 +} +``` + +### 2. Add `pre_build_command` to `pkg.json` + +```json +"pre_build_command": { + "default": { + "default": { + "default": { + "cc": "gcc", + "cc_options": "-g -std=gnu99 %input% -o %output%", + "lita_options": ["-run"] + } + } + } +} +``` + +### 3. Place your templates + +Put `.lt` files under `bin/static/templates/` (or the directory you configured). The directory is scanned recursively; only `.lt` files are processed. + +### 4. Build + +```sh +litac build +``` + +The pre-build step runs first, writing the generated files into `src/gen/`. The main build then compiles everything including those generated sources. + +## Example Template + +`bin/static/templates/greeting.html.lt`: + +```html +<% @type "models/user" :: User %> + + + +

Hello, <%= input.name %>!

+ + +``` + +Route handler: + +```litac +func greetingHandler(req: *HttpRequest, resp: *HttpResponse) : Status { + var user = User{ .name = $"World" } + return resp.tmpl_greeting_html_lt(&user) +} +``` diff --git a/pkg.json b/pkg.json index 892ed8e..0987a41 100644 --- a/pkg.json +++ b/pkg.json @@ -4,21 +4,6 @@ "type" : "executable", "repo" : "", - "pre_build_command": { - "default" : { - "default" : { - "default" : { - "cc" : "gcc", - "cc_options" : "-g -std=gnu99 %input% -o %output% -D_CRT_SECURE_NO_WARNINGS", - "lita_options" : [ - "-verbose", - "-run" - ] - } - } - } - }, - "build_command" : { "default" : { "linux" : { diff --git a/src/http_common.lita b/src/http_common.lita index 4fc53df..5bd4383 100644 --- a/src/http_common.lita +++ b/src/http_common.lita @@ -79,6 +79,7 @@ public enum Status { ERROR_TEMPLATE_NO_END_TAG, ERROR_TEMPLATE_START_TAG_BEFORE_END_TAG, ERROR_TEMPLATE_INVALID_COMMAND, + ERROR_TEMPLATE_NOT_FOUND, // HTTP parsing (formerly ParseStatus) ERROR_HTTP_PARSING_CHUNK_INCOMPLETE, diff --git a/src/http_server.lita b/src/http_server.lita index c2206ba..51e588c 100644 --- a/src/http_server.lita +++ b/src/http_server.lita @@ -94,6 +94,7 @@ public struct HttpConfig { // static file directory fileServerPath: String = $"" // the FileSystem path to look for static assets + templatePath: String = $"" // the FileSystem path to look for .lt template files publicServerPath: String = $"/static/" // the URI path that denotes we're looking for static assets // the server side keep alive time in seconds diff --git a/src/pre_main.lita b/src/pre_main.lita deleted file mode 100644 index 474dbeb..0000000 --- a/src/pre_main.lita +++ /dev/null @@ -1,31 +0,0 @@ -import "std/libc" -import "std/mem" -import "std/string" -import "std/string/buffer" -import "std/string/builder" - -import "http_common" -import "template" - -func main(len: i32, args: **char) : i32 { - var templateInputDir = "static/templates/" - if(len > 1) { - templateInputDir = args[1] - } - printf("Generating templates from '%s'...\n", templateInputDir) - - var allocator = defaultAllocator - - var template: Template{} - template.init(allocator) - var status = template.build(templateInputDir.toString()) ?: { - printf("Error building templates: %s\n", StatusAsStr(status)) - return 1 - } - - printf("Generated code:\n") - printf("%s\n", template.generatedCode.cStr()) - // TODO: generate lookup table for each template - // resp.template(TemplateId.PATH_TO_TEMPLATE], input) - return 0 -} \ No newline at end of file diff --git a/src/template.lita b/src/template.lita index 15ee6ef..79547c2 100644 --- a/src/template.lita +++ b/src/template.lita @@ -5,38 +5,195 @@ import "std/string/buffer" import "std/system" import "std/fs" import "std/io" -import "std/map" +import "std/array" import "std/mem" import "std/assert" import "std/ascii" import "http_common" +// Scans inputDir for .lt templates, generates .lita source files into outputDir. +// Writes one tmpl_.lita per template and a templates.lita dispatch file. +public func GenerateTemplates( + inputDir: String, + outputDir: *const char, + allocator: *const Allocator +) : Status { + var t: Template + t.init(allocator) + + var status = t.build(inputDir) + ?: return status + + if(t.entries.empty()) { + return Status.OK + } + + Mkdir(outputDir) + + for(var i = 0; i < t.entries.size(); i += 1) { + var entry = t.entries.getPtr(i) + var name = entry.name.toString() + var content = entry.content.toString() + + var temp:[MAX_PATH]char; + var filePath = StringBufferInit(temp, MAX_PATH, 0) + filePath.append("%s/tmpl_%.*s.lita", outputDir, name.length, name.buffer) + + var fs = WriteFile(filePath.cStr(), content.buffer, content.length as (usize)) + if(fs != FileStatus.Ok) { + return Status.ERROR_IO_ERROR + } + } + + var dispatch = StringBuilderInit(4096, allocator) + defer dispatch.free() + GenerateDispatch(&t, outputDir, &dispatch) + + var temp:[MAX_PATH]char; + var dispatchPath = StringBufferInit(temp, MAX_PATH, 0) + dispatchPath.append("%s/templates.lita", outputDir) + + var ds = WriteFile(dispatchPath.cStr(), dispatch.cStr(), dispatch.length as (usize)) + if(ds != FileStatus.Ok) { + return Status.ERROR_IO_ERROR + } + + return Status.OK +} + +func GenerateDispatch(t: *Template, genOutputDir: *const char, output: *StringBuilder) { + BaseImports(output) + output.appendStr("\n") + + var typeImports = ArrayInit(8, t.entries.getPtr(0).name.alloc) + defer typeImports.free() + + for(var i = 0; i < t.entries.size(); i += 1) { + var entry = t.entries.getPtr(i) + var name = entry.name.toString() + output.append("import \"gen/tmpl_%.*s\"\n", name.length, name.buffer) + + if(entry.typeModule.length > 0) { + var mod = entry.typeModule.toString() + var seen = false + for(var j = 0; j < typeImports.size(); j += 1) { + if(typeImports.get(j).equals(mod)) { + seen = true + break + } + } + if(!seen) { + typeImports.add(mod) + output.append("import \"%.*s\"\n", mod.length, mod.buffer) + } + } + } + output.appendStr("\n") + + // String dispatch — takes *void; typed templates cast input to their concrete type + output.appendStr("public func (this: *HttpResponse) template(name: *const char, input: *void) : Status {\n") + for(var i = 0; i < t.entries.size(); i += 1) { + var entry = t.entries.getPtr(i) + var name = entry.name.toString() + if(entry.typeName.length > 0) { + var typeName = entry.typeName.toString() + output.append( + " if(StringInit(name).equals($\"%.*s\")) { return %.*s_WriteTemplate(this, input as (*%.*s)) }\n", + name.length, name.buffer, + name.length, name.buffer, + typeName.length, typeName.buffer + ) + } else { + output.append( + " if(StringInit(name).equals($\"%.*s\")) { return %.*s_WriteTemplate(this, input) }\n", + name.length, name.buffer, + name.length, name.buffer + ) + } + } + output.appendStr(" return Status.ERROR_TEMPLATE_NOT_FOUND\n}\n\n") + + // Named helpers per template — typed for @type templates, *void otherwise + for(var i = 0; i < t.entries.size(); i += 1) { + var entry = t.entries.getPtr(i) + var name = entry.name.toString() + if(entry.typeName.length > 0) { + var typeName = entry.typeName.toString() + output.append( + "public func (this: *HttpResponse) tmpl_%.*s(input: *%.*s) : Status {\n return %.*s_WriteTemplate(this, input)\n}\n\n", + name.length, name.buffer, + typeName.length, typeName.buffer, + name.length, name.buffer + ) + } else { + output.append( + "public func (this: *HttpResponse) tmpl_%.*s(input: *void) : Status {\n return %.*s_WriteTemplate(this, input)\n}\n\n", + name.length, name.buffer, + name.length, name.buffer + ) + } + } +} + +// Per-template generation context — tracks imports, @type info, and partial count +struct GenContext { + allocator: *const Allocator + imports: Array + partialCount: i32 + typeName: String // from <% @type "module" :: TypeName %>, empty if absent + typeModule: String // module path for the declared type +} + +func (ctx: *GenContext) init(allocator: *const Allocator) { + ctx.allocator = allocator + ctx.imports.init(8, allocator) + ctx.partialCount = 0 + ctx.typeName = String{} + ctx.typeModule = String{} +} + +func (ctx: *GenContext) free() { + ctx.imports.free() +} + +func (ctx: *GenContext) addImport(path: String) { + for(var i = 0; i < ctx.imports.size(); i += 1) { + if(ctx.imports.get(i).equals(path)) { + return; + } + } + ctx.imports.add(path) +} + +// One generated .lita file per .lt template +public struct TemplateEntry { + name: StringBuilder // sanitized function name prefix, e.g. "greeting_html_lt" + content: StringBuilder // complete generated .lita file content + typeName: StringBuilder // declared type name (e.g. "User"), empty if no @type + typeModule: StringBuilder// declared type module (e.g. "models/user"), empty if no @type +} + public struct Template { allocator: *const Allocator fileBuffer: StringBuilder - generatedCode: StringBuilder - - templates: Map + entries: Array } public func (this: *Template) init(allocator: *const Allocator) { this.allocator = allocator - this.fileBuffer = StringBuilderInit(1024, allocator) - this.generatedCode = StringBuilderInit(1024, allocator) - - this.templates = StringMap($"", 16, allocator) + this.fileBuffer.init(1024, allocator) + this.entries.init(16, allocator) } -public func (this: *Template) build(assetPath: String): Status { +public func (this: *Template) build(assetPath: String) : Status { return ScanForTemplates(this, assetPath) } func ScanForTemplates(template: *Template, assetPath: String) : Status { var temp:[MAX_PATH]char; var path = StringBufferInit(temp, MAX_PATH, 0) - path.append("%s", assetPath) - + path.append("%.*s", assetPath.length, assetPath.buffer) return ScanDirectory(template, path) } @@ -67,144 +224,230 @@ func ScanDirectory(template: *Template, path: *StringBuffer) : Status { } template.fileBuffer.clear() - var status = ReadFileFully(path.cStr(), template.fileBuffer) ?: return Status.ERROR_FILE_NOT_FOUND - var templateStatus = GenerateFromTemplate( + var entry = TemplateEntry{ + .name = StringBuilderInit(64, template.allocator), + .content = StringBuilderInit(1024, template.allocator) + } + + var genStatus = GenerateFromTemplate( template.allocator, fileName, template.fileBuffer.toString(), - &template.generatedCode - ) ?: return templateStatus + &entry + ) ?: return genStatus + + template.entries.add(entry) } } return Status.OK } -func LoadTemplate( - allocator: *const Allocator, - path: String, - fileBuffer: *StringBuilder, - output: *StringBuilder -) : Status { - - var status = ReadFileFullyStr(path, fileBuffer) - ?: return Status.ERROR_FILE_NOT_FOUND - - var templateStatus = GenerateBody( - allocator, - fileBuffer.toString(), - output - ) ?: return templateStatus - - return Status.OK -} - +// Generates a complete .lita source file for one template into entry. +// GenerateBody runs first (may call SanitizeName for @load), then we +// sanitize the template name to avoid clobbering the @static buffer. public func GenerateFromTemplate( allocator: *const Allocator, name: String, template: String, - output: *StringBuilder + entry: *TemplateEntry ) : Status { - var sname = SanatizeName(name) - // TODO: Escape function name - output.append(""" - public func %.*s_WriteTemplate( - resp: *HttpResponse, - input: T - ) : Status { - var out = resp.body - """, sname.length, sname.buffer) - - var status = GenerateBody(allocator, template, output) + var ctx = GenContext{} + ctx.init(allocator) + defer ctx.free() + + var body = StringBuilderInit(1024, allocator) + defer body.free() + + var status = GenerateBody(allocator, &ctx, template, &body) ?: return status - output.append(""" - return Status.OK + // Sanitize after GenerateBody — avoids clobbering the @static buffer mid-body + var sname = SanitizeName(name) + entry.name.appendString(sname) + + // Copy @type info into entry if declared + if(!ctx.typeName.empty()) { + entry.typeName.init(ctx.typeName.length + 1, allocator) + entry.typeName.appendString(ctx.typeName) + entry.typeModule.init(ctx.typeModule.length + 1, allocator) + entry.typeModule.appendString(ctx.typeModule) + } + + var output = &entry.content + BaseImports(output) + + // Imports declared in the template via <% @import "..." %> + // (the type module from @type is already added via addImport) + for(var i = 0; i < ctx.imports.size(); i += 1) { + var imp = ctx.imports.get(i) + output.append("import \"%.*s\"\n", imp.length, imp.buffer) + } + output.appendStr("\n") + + // Function signature: *TypeName when @type declared, *void otherwise + if(!ctx.typeName.empty()) { + output.append( + "public func %.*s_WriteTemplate(\n resp: *HttpResponse,\n input: *%.*s\n) : Status {\n var out = &resp.body\n", + sname.length, sname.buffer, + ctx.typeName.length, ctx.typeName.buffer + ) + } else { + output.append( + "public func %.*s_WriteTemplate(\n resp: *HttpResponse,\n input: *void\n) : Status {\n var out = &resp.body\n", + sname.length, sname.buffer + ) } - """) + + output.appendString(body.toString()) + output.appendStr(" return Status.OK\n}\n") return Status.OK } public func GenerateBody( allocator: *const Allocator, + ctx: *GenContext, template: String, output: *StringBuilder ) : Status { var iter = template.split($"<%") while(iter.hasNext()) { - var s = iter.next().trim() + var s = iter.next() - output.append( - "\n output.append(\"%%s\", \"\"\"%.*s\"\"\")\n", - s.length, s.buffer - ) + if(s.length > 0) { + // appendStrn avoids format-string injection from raw HTML text + output.append(" out.appendStrn(\"\"\"%.*s\"\"\", %d)\n", s.length, s.buffer, s.length) + } var extra = iter.remaining() - if (!extra.empty()) { + if(!extra.empty()) { + // <%=expr%> shorthand — expression output + var isExpression = extra.startsWith($"=") + if(isExpression) { + extra = extra.substring(1) + } + var endIndex = extra.indexOf($"%>") if(endIndex < 0) { return Status.ERROR_TEMPLATE_NO_END_TAG } - // check if there is an embedded <% var startIndex = extra.indexOf($"<%") if(startIndex > -1 && startIndex < endIndex) { return Status.ERROR_TEMPLATE_START_TAG_BEFORE_END_TAG } - // output the escaped code var code = extra.substring(0, endIndex) - var status = HandleDirective(allocator, &code, output) - ?: return status - output.append("%.*s\n", code.length, code.buffer) + if(isExpression) { + var expr = code.trim() + output.append(" out.appendString(%.*s)\n", expr.length, expr.buffer) + } else { + var dirStatus = HandleDirective(allocator, ctx, &code, output) + ?: return dirStatus + if(code.length > 0) { + output.append("%.*s\n", code.length, code.buffer) + } + } - // move the iterator to the start of the next unescaped section iter = extra.substring(endIndex + 2).split($"<%") } } return Status.OK } - func HandleDirective( allocator: *const Allocator, + ctx: *GenContext, code: *String, output: *StringBuilder ) : Status { - var block = *code; - block = block.trim() + var block = (*code).trim() + + // <% @import "module/path" %> — add a LitaC import to this template's file + if(block.startsWith($"@import ")) { + var index = block.endIndexOf($"@import ") + assert(index > -1) - if(!block.startsWith($"$")) { + var modulePath = ReadString(block.substring(index)) + if(modulePath.empty()) { + printf("'@import' must be followed by a quoted module path\n") + return Status.ERROR_TEMPLATE_INVALID_COMMAND + } + ctx.addImport(modulePath) + ;*code = $"" + return Status.OK + } + + // <% @type "module/path" :: TypeName %> — declare the input type + // Generates a typed function signature and adds the module import. + if(block.startsWith($"@type ")) { + var index = block.endIndexOf($"@type ") + assert(index > -1) + + var modulePath = ReadString(block.substring(index)) + if(modulePath.empty()) { + printf("'@type' must be followed by a quoted module path\n") + return Status.ERROR_TEMPLATE_INVALID_COMMAND + } + + var afterModule = block.substring(index + modulePath.length + 2).trim() + if(!afterModule.startsWith($"::")) { + printf("'@type' module path must be followed by ':: TypeName'\n") + return Status.ERROR_TEMPLATE_INVALID_COMMAND + } + + var typeName = afterModule.substring(2).trim() + if(typeName.empty()) { + printf("'@type' must include a type name after '::'\n") + return Status.ERROR_TEMPLATE_INVALID_COMMAND + } + + ctx.typeName = typeName + ctx.typeModule = modulePath + ctx.addImport(modulePath) + ;*code = $"" return Status.OK } - // load another template - if(block.startsWith($"$load ")) { - var index = block.endIndexOf($"$load ") + + // <% @load "file.lt" [field] %> — emit a direct call to the partial's function + if(block.startsWith($"@load ")) { + var index = block.endIndexOf($"@load ") assert(index > -1) var filename = ReadString(block.substring(index)) if(filename.empty()) { - printf( - "The template command '$load' must be followed by a string representing the file path to the template to load" - ) + printf("'@load' must be followed by a quoted template file path\n") return Status.ERROR_TEMPLATE_INVALID_COMMAND } - var fileBuffer = StringBuilderInit(1024, allocator) - var status = LoadTemplate( - allocator, - filename, - fileBuffer, - output - ) ?: return status + var afterFilename = block.substring(index + filename.length + 2).trim() + var funcName = SanitizeName(BaseName(filename)) + var pcount = ctx.partialCount + ctx.partialCount += 1 + + if(afterFilename.empty()) { + output.append( + " var _p%d = %.*s_WriteTemplate(resp, input)\n if(_p%d != Status.OK) { return _p%d }\n", + pcount, funcName.length, funcName.buffer, pcount, pcount + ) + } else { + output.append( + " var _p%d = %.*s_WriteTemplate(resp, %.*s)\n if(_p%d != Status.OK) { return _p%d }\n", + pcount, funcName.length, funcName.buffer, + afterFilename.length, afterFilename.buffer, + pcount, pcount + ) + } - ;*code = block.substring(index + filename.length + 2) + ;*code = $"" + return Status.OK } + return Status.OK } @@ -228,15 +471,38 @@ public func ReadString(str: String) : String { } } -// no thread-safe, uses static value -func SanatizeName( - name: String -) : String { +// Returns the filename portion of a path (after the last path separator) +func BaseName(path: String) : String { + var sep = GetSystemPathSeparator() + var lastIdx = -1 + var i = 0 + while(true) { + var found = path.indexOfAt(sep, i) + if(found < 0) { break } + lastIdx = found + i = found + 1 + } + if(lastIdx < 0) { return path } + return path.substring(lastIdx + 1) +} + +func BaseImports(output: *StringBuilder) { + // Base imports always required + output.appendStr("import \"http_response\"\n") + output.appendStr("import \"http_request\"\n") + output.appendStr("import \"http_common\"\n") + output.appendStr("import \"std/string\"\n") + output.appendStr("import \"std/string/builder\"\n") + output.appendStr("import \"std/string/buffer\"\n") +} + +// Not thread-safe — uses a @static buffer. Use result before calling again. +public func SanitizeName(name: String) : String { @static var buffer:[MAX_PATH]char; var str = StringBufferInit(buffer, MAX_PATH, 0) - for(var i = 0; i < name.length; i+=1) { + for(var i = 0; i < name.length; i += 1) { var c = name.buffer[i] if(!c.isAlphanumeric()) { c = '_' @@ -245,4 +511,3 @@ func SanatizeName( } return str.toString() } - diff --git a/test/template_test.lita b/test/template_test.lita index d00209c..74dfd58 100644 --- a/test/template_test.lita +++ b/test/template_test.lita @@ -1,13 +1,12 @@ import "std/assert" import "std/string" import "std/string/builder" +import "std/string/buffer" import "std/mem" import "template" import "http_common" -// Bug fix: str.buffer[0] accessed without checking length first. -// An empty string (or whitespace-only that trims to empty) must not crash. @test func testReadStringEmptyInput() { var empty = ReadString(StringInit("")) @@ -16,40 +15,110 @@ func testReadStringEmptyInput() { var whitespace = ReadString(StringInit(" ")) assert(whitespace.length == 0) - // Sanity-check: a well-formed quoted string still works. var result = ReadString(StringInit("\"hello\"")) assert(result.equals($"hello")) - // Input that starts with a non-quote character should also return empty. var noQuote = ReadString(StringInit("hello")) assert(noQuote.length == 0) } @test -func testTemplate() { - var output = StringBuilderInit(1024) +func testSanitizeName() { + assert(SanitizeName($"test.html.lt").equals($"test_html_lt")) + assert(SanitizeName($"my-template.lt").equals($"my_template_lt")) + assert(SanitizeName($"simple").equals($"simple")) +} + +@test +func testGenerateBodyVoid() { + // Without @type: input is *void, user casts manually in template body + var entry = TemplateEntry{ + .name = StringBuilderInit(64, defaultAllocator), + .content = StringBuilderInit(1024, defaultAllocator) + } + + var status = GenerateFromTemplate( + defaultAllocator, + $"page.html.lt", + """ + + <% var data = input as *PageData %> +

<%= data.title %>

+ + +""".toString(), + &entry + ) + + assert(status == Status.OK) + assert(entry.name.length > 0) + assert(entry.typeName.length == 0) + + var content = entry.content.toString() + // untyped function takes *void + assert(content.contains($"input: *void")) + assert(content.contains($"page_html_lt_WriteTemplate")) + assert(content.contains($"out.appendString(data.title)")) +} + +@test +func testGenerateBodyTyped() { + // With @type: input is *User, no manual cast needed, named helper gets typed signature + var entry = TemplateEntry{ + .name = StringBuilderInit(64, defaultAllocator), + .content = StringBuilderInit(1024, defaultAllocator) + } + var status = GenerateFromTemplate( defaultAllocator, - $"Tony", - """ - - <%@import "/title.lt" %> - - <% - for(var i = 0; i < input.length; i+=1) { - var element = input.getPtr(i) - %> -
  • - <% out.append("%.*s", element.name.length, element.name.buffer) %> -
  • - <% - } - %> - - - """.toString(), - output + $"greeting.html.lt", + """<% @type "models/user" :: User %> + +Hello, <%= input.name %> + +""".toString(), + &entry ) + assert(status == Status.OK) - assert(output.length > 0) + assert(entry.name.toString().equals($"greeting_html_lt")) + assert(entry.typeName.toString().equals($"User")) + assert(entry.typeModule.toString().equals($"models/user")) + + var content = entry.content.toString() + // typed function takes *User + assert(content.contains($"input: *User")) + // type module auto-imported + assert(content.contains($"import \"models/user\"")) + // expression shorthand expanded + assert(content.contains($"out.appendString(input.name)")) +} + +@test +func testGenerateBodyPartial() { + var entry = TemplateEntry{ + .name = StringBuilderInit(64, defaultAllocator), + .content = StringBuilderInit(1024, defaultAllocator) + } + + var status = GenerateFromTemplate( + defaultAllocator, + $"page.html.lt", + """ +<% @load "templates/header.html.lt" %> + +<% @load "templates/card.html.lt" input.card %> + + +""".toString(), + &entry + ) + + assert(status == Status.OK) + var content = entry.content.toString() + + // partial without field — passes input unchanged + assert(content.contains($"header_html_lt_WriteTemplate(resp, input)")) + // partial with field — passes sub-field + assert(content.contains($"card_html_lt_WriteTemplate(resp, input.card)")) }