v2 / vlib / x / templating / dtm2 / dynamic_template_manager2.v
821 lines · 766 sloc · 27.85 KB · 3eff1b83cf719199d0ff6f63524da63c9294ffd5
Raw
1module dtm2
2
3import hash
4import json
5import os
6import strings
7
8// dtm2 is the modern runtime renderer for Dynamic Template Manager.
9// It keeps DTM dynamic, meaning templates can still be edited on disk without
10// recompiling the application, while moving the hot path to a parsed-template
11// cache instead of a rendered-output cache. File extensions are configurable,
12// but every template ultimately renders in either HTML mode or text mode.
13//
14// The intended runtime model is one long-lived Manager per application or
15// rendering context. Tests and benchmarks may create short-lived managers, but
16// normal applications should reuse a manager so parsed templates and resolved
17// paths stay hot.
18const message_signature = '[Dynamic Template Manager 2]'
19const internal_server_error = 'Internal Server Error'
20const include_html_key_suffix = '_#includehtml'
21const include_directive = '@include '
22const max_include_depth = 32
23const max_extension_config_size = 64 * 1024
24const default_extension_config_filename = 'dtm2_extensions.json'
25const segment_instruction_size = 9
26const segment_kind_text = u8(0)
27const segment_kind_placeholder = u8(1)
28
29// These tags are the compatibility allow-list for the historical `_#includehtml`
30// placeholder suffix. Any other tag is still escaped.
31const allowed_html_tags = ['<div>', '</div>', '<h1>', '</h1>', '<h2>', '</h2>', '<h3>', '</h3>',
32 '<h4>', '</h4>', '<h5>', '</h5>', '<h6>', '</h6>', '<p>', '</p>', '<br>', '<hr>', '<span>',
33 '</span>', '<ul>', '</ul>', '<ol>', '</ol>', '<li>', '</li>', '<dl>', '</dl>', '<dt>', '</dt>',
34 '<dd>', '</dd>', '<menu>', '</menu>', '<table>', '</table>', '<caption>', '</caption>', '<th>',
35 '</th>', '<tr>', '</tr>', '<td>', '</td>', '<thead>', '</thead>', '<thread>', '</thread>',
36 '<tbody>', '</tbody>', '<tfoot>', '</tfoot>', '<col>', '</col>', '<colgroup>', '</colgroup>',
37 '<header>', '</header>', '<footer>', '</footer>', '<main>', '</main>', '<section>', '</section>',
38 '<article>', '</article>', '<aside>', '</aside>', '<details>', '</details>', '<dialog>',
39 '</dialog>', '<data>', '</data>', '<summary>', '</summary>']!
40
41pub enum TemplateType {
42 html
43 text
44}
45
46// ExtensionConfig is the JSON representation accepted by
47// `ManagerParams.extension_config_file`.
48//
49// Example:
50//
51// ```json
52// {
53// "html": [".html", ".htm", ".xml", ".view"],
54// "text": [".txt", ".mail"]
55// }
56// ```
57pub struct ExtensionConfig {
58pub:
59 html []string
60 text []string
61}
62
63// TemplateDependency records the file metadata that decides whether a parsed
64// template is still fresh. Includes are tracked as dependencies of the parent
65// template, so editing a partial can invalidate the compiled parent tree.
66struct TemplateDependency {
67 path string
68 modified_at i64
69 size u64
70 content_hash u64
71}
72
73// CompiledTemplate is the parsed representation stored by Manager.
74//
75// It intentionally stores a compact instruction string instead of slices of
76// segment structs. The instructions contain offsets into the owned `content`
77// string, which keeps the cache compact and avoids keeping fragile string-slice
78// graphs alive across many -prod renders.
79@[heap]
80struct CompiledTemplate {
81 // Rendering mode inferred from the source file extension.
82 template_type TemplateType
83 // Full template content after static @include expansion.
84 content string
85 // Compact metadata used to decide whether this compiled tree is stale.
86 dependency_signature string
87 // Binary instruction stream: one fixed-size record per text/placeholder segment.
88 instructions string
89 // Number of text and placeholder records in `instructions`.
90 segment_count int
91 // Static-text size hint used to preallocate the render buffer.
92 estimated_size int
93}
94
95// Manager owns the runtime state of a dtm2 renderer.
96//
97// It caches resolved template paths and parsed template trees, but never caches
98// rendered HTML responses. This keeps the new engine deterministic and leaves
99// legacy rendered-cache compatibility in `x.templating.dtm`.
100@[heap]
101pub struct Manager {
102mut:
103 // Base directory for relative template paths.
104 template_dir string
105 // Enables the lightweight deterministic HTML whitespace compressor.
106 compress_html bool
107 // When true, source and include files are stat-checked before cache reuse.
108 reload_modified_templates bool
109 // Maps caller-provided template paths to canonical source paths.
110 resolved_template_paths map[string]string
111 // Parsed-template cache keyed by canonical source path.
112 compiled_templates map[string]&CompiledTemplate
113 // User-provided extension-to-render-mode overrides. Built-in extensions are
114 // resolved without allocating a per-manager default map.
115 template_extensions map[string]TemplateType
116}
117
118// ManagerParams configure a dtm2 Manager.
119@[params]
120pub struct ManagerParams {
121pub:
122 // Root directory used when `expand()` receives a relative template path.
123 // If empty, `<executable directory>/templates` is used.
124 template_dir string
125 // Compresses HTML output by removing newlines/tabs and redundant spaces.
126 compress_html bool = true
127 // Re-check source files and includes before reusing a parsed template tree.
128 // Disable it for maximum hot-path throughput when templates are immutable.
129 reload_modified_templates bool = true
130 // Additional or overriding extension mappings.
131 // Default mappings are: `.html`, `.htm`, `.xml` => HTML mode and
132 // `.txt`, `.text` => text mode.
133 template_extensions map[string]TemplateType
134 // Optional JSON file containing extension mappings. It is merged after the
135 // default mappings and before `template_extensions`, so explicit code
136 // configuration wins over the file. If empty, DTM2 tries to load
137 // `<template_dir>/dtm2_extensions.json` when that file exists.
138 extension_config_file string
139}
140
141// RenderParams configure one render call.
142@[params]
143pub struct RenderParams {
144pub:
145 // Placeholder values keyed by their template name without the `@` prefix.
146 // Values are escaped by default.
147 placeholders &map[string]string = &map[string]string{}
148 // Prefix written back when a placeholder is missing. The default preserves
149 // the original `@placeholder` text.
150 missing_placeholder_prefix string = '@'
151}
152
153// initialize creates a dynamic template manager rooted at `params.template_dir`.
154//
155// The returned manager should normally be kept and reused. Reusing it is what
156// gives dtm2 its parsed-template and path-resolution cache benefits.
157pub fn initialize(params ManagerParams) &Manager {
158 raw_template_dir := if params.template_dir == '' {
159 os.join_path(os.dir(os.executable()), 'templates')
160 } else {
161 params.template_dir
162 }
163 template_dir := canonical_template_dir(raw_template_dir)
164 template_extensions := build_template_extensions(params, template_dir)
165 return &Manager{
166 template_dir: template_dir.clone()
167 compress_html: params.compress_html
168 reload_modified_templates: params.reload_modified_templates
169 resolved_template_paths: map[string]string{}
170 compiled_templates: map[string]&CompiledTemplate{}
171 template_extensions: template_extensions
172 }
173}
174
175fn canonical_template_dir(template_dir string) string {
176 return os.real_path(template_dir)
177}
178
179fn build_template_extensions(params ManagerParams, template_dir string) map[string]TemplateType {
180 mut extensions := map[string]TemplateType{}
181 config_path := extension_config_path(params, template_dir)
182 if config_path != '' {
183 file_extensions := read_extension_config_file(config_path) or {
184 eprintln('${message_signature} ${err.msg()}')
185 map[string]TemplateType{}
186 }
187 merge_template_extensions(mut extensions, file_extensions)
188 }
189 merge_template_extensions(mut extensions, params.template_extensions)
190 return extensions
191}
192
193fn extension_config_path(params ManagerParams, template_dir string) string {
194 if params.extension_config_file != '' {
195 return params.extension_config_file
196 }
197 default_path := os.join_path(template_dir, default_extension_config_filename)
198 if os.exists(default_path) {
199 return default_path
200 }
201 return ''
202}
203
204fn read_extension_config_file(config_path string) !map[string]TemplateType {
205 if !os.exists(config_path) {
206 return error('extension config file "${config_path}" not found')
207 }
208 if os.is_dir(config_path) {
209 return error('extension config path "${config_path}" is a directory')
210 }
211 config_stat := os.stat(config_path)!
212 if config_stat.size > u64(max_extension_config_size) {
213 return error('extension config file "${config_path}" is larger than ${max_extension_config_size} bytes')
214 }
215 raw_config := os.read_file(config_path)!
216 config := json.decode(ExtensionConfig, raw_config)!
217 mut extensions := map[string]TemplateType{}
218 merge_template_extension_list(mut extensions, config.html, .html)
219 merge_template_extension_list(mut extensions, config.text, .text)
220 return extensions
221}
222
223fn merge_template_extensions(mut target map[string]TemplateType, source map[string]TemplateType) {
224 for ext, template_type in source {
225 normalized := validate_template_extension(ext) or {
226 eprintln('${message_signature} ${err.msg()}')
227 continue
228 }
229 target[normalized] = template_type
230 }
231}
232
233fn merge_template_extension_list(mut target map[string]TemplateType, extensions []string, template_type TemplateType) {
234 for ext in extensions {
235 normalized := validate_template_extension(ext) or {
236 eprintln('${message_signature} ${err.msg()}')
237 continue
238 }
239 target[normalized] = template_type
240 }
241}
242
243fn normalize_template_extension(ext string) string {
244 trimmed := ext.trim_space().to_lower()
245 if trimmed == '' {
246 return ''
247 }
248 if trimmed.starts_with('.') {
249 return trimmed
250 }
251 return '.${trimmed}'
252}
253
254fn validate_template_extension(ext string) !string {
255 normalized := normalize_template_extension(ext)
256 if normalized.len < 2 {
257 return error('ignoring invalid template extension "${ext}"')
258 }
259 for i := 1; i < normalized.len; i++ {
260 c := normalized[i]
261 if (c >= `a` && c <= `z`) || (c >= `0` && c <= `9`) || c == `_` || c == `-` || c == `.` {
262 continue
263 }
264 return error('ignoring invalid template extension "${ext}"')
265 }
266 return normalized
267}
268
269// compiled_template_count is intentionally exposed for tests and diagnostics. In
270// dtm2 it counts parsed template trees currently held by the manager.
271pub fn (m &Manager) compiled_template_count() int {
272 return m.compiled_templates.len
273}
274
275// expand renders `template_path` with the provided placeholders.
276//
277// `template_path` can be absolute or relative to the manager's template
278// directory. Supported extensions come from the manager extension table.
279// Errors are reported to stderr and return the legacy-compatible
280// `Internal Server Error` string.
281pub fn (mut m Manager) expand(template_path string, params RenderParams) string {
282 source_path := m.cached_template_path(template_path) or {
283 eprintln('${message_signature} ${err.msg()}')
284 return internal_server_error
285 }
286 compiled := m.compiled_template_for_path(source_path) or {
287 eprintln('${message_signature} ${err.msg()}')
288 return internal_server_error
289 }
290 if m.compress_html && compiled.template_type == .html {
291 rendered := render_compiled_template(compiled, params)
292 compressed := compress_html_output(rendered)
293 return compressed.clone()
294 }
295 rendered := render_compiled_template(compiled, params)
296 return rendered.clone()
297}
298
299// cached_template_path reuses canonical paths when reload checks are disabled.
300// When reload checks are enabled, it revalidates the current real path so a
301// replaced symlink cannot bypass the template directory boundary.
302fn (mut m Manager) cached_template_path(template_path string) !string {
303 if source_path := m.resolved_template_paths[template_path] {
304 if m.reload_modified_templates {
305 current_source_path := m.resolve_template_path(template_path)!
306 if current_source_path != source_path {
307 m.resolved_template_paths[template_path] = current_source_path
308 return current_source_path
309 }
310 }
311 return source_path
312 }
313 source_path := m.resolve_template_path(template_path)!
314 m.resolved_template_paths[template_path] = source_path
315 return source_path
316}
317
318// compiled_template_for_path returns the parsed tree for a canonical source
319// path. When reload checks are enabled, dependency metadata decides whether the
320// tree can be reused or must be rebuilt from disk.
321fn (mut m Manager) compiled_template_for_path(source_path string) !&CompiledTemplate {
322 if compiled := m.compiled_templates[source_path] {
323 if !m.reload_modified_templates || compiled.dependencies_are_fresh(m.template_dir) {
324 return compiled
325 }
326 }
327 compiled := compile_template_from_file(source_path, m.template_dir, m.template_extensions,
328 m.reload_modified_templates)!
329 m.compiled_templates[source_path] = compiled
330 return compiled
331}
332
333// compile_template_from_file reads a template, expands static includes, parses
334// placeholders, and stores dependency metadata for future invalidation.
335fn compile_template_from_file(source_path string, template_root string, template_extensions map[string]TemplateType, track_dependencies bool) !&CompiledTemplate {
336 canonical_path := os.real_path(source_path)
337 ensure_path_inside_template_dir(canonical_path, template_root)!
338 template_type := template_type_from_path(canonical_path, template_extensions)!
339 content, dependencies := read_template_with_includes(canonical_path, template_root, 0,
340 track_dependencies)!
341 instructions, estimated_size, segment_count := parse_segments(content)
342 dependency_signature := encode_dependencies(dependencies)
343 return &CompiledTemplate{
344 template_type: template_type
345 content: copy_string(content)
346 dependency_signature: copy_string(dependency_signature)
347 instructions: copy_string(instructions)
348 segment_count: segment_count
349 estimated_size: estimated_size
350 }
351}
352
353// copy_string forces cached strings to own their memory. Cached templates live
354// beyond the stack frame that built them, so the cache should not retain
355// accidental views into temporary buffers.
356fn copy_string(value string) string {
357 mut bytes := []u8{len: value.len}
358 for i := 0; i < value.len; i++ {
359 bytes[i] = value[i]
360 }
361 copied := bytes.bytestr()
362 return copied.clone()
363}
364
365// dependencies_are_fresh checks root and included files. The content hash closes
366// the same-second, same-size edit window left by second-resolution mtimes.
367fn (compiled &CompiledTemplate) dependencies_are_fresh(template_root string) bool {
368 signature := compiled.dependency_signature
369 mut offset := 0
370 for offset < signature.len {
371 first_sep := index_byte_from(signature, `|`, offset) or { return false }
372 second_sep := index_byte_from(signature, `|`, first_sep + 1) or { return false }
373 third_sep := index_byte_from(signature, `|`, second_sep + 1) or { return false }
374 line_end := index_byte_from(signature, `\n`, third_sep + 1) or { signature.len }
375 size := signature[first_sep + 1..second_sep].i64()
376 if size < 0 {
377 return false
378 }
379 content_hash := signature[second_sep + 1..third_sep].u64()
380 path := signature[third_sep + 1..line_end]
381 current_path := os.real_path(path)
382 ensure_path_inside_template_dir(current_path, template_root) or { return false }
383 if current_path != path {
384 return false
385 }
386 stat := os.stat(path) or { return false }
387 if stat.mtime != signature[offset..first_sep].i64() || stat.size != u64(size) {
388 return false
389 }
390 content := os.read_file(path) or { return false }
391 if content_fingerprint(content) != content_hash {
392 return false
393 }
394 offset = line_end + 1
395 }
396 return true
397}
398
399fn index_byte_from(value string, needle u8, start int) ?int {
400 for i := start; i < value.len; i++ {
401 if value[i] == needle {
402 return i
403 }
404 }
405 return none
406}
407
408fn encode_dependencies(dependencies []TemplateDependency) string {
409 mut out := strings.new_builder(dependencies.len * 64)
410 for dependency in dependencies {
411 out.writeln('${dependency.modified_at}|${dependency.size}|${dependency.content_hash}|${dependency.path}')
412 }
413 encoded := out.str()
414 return encoded.clone()
415}
416
417// resolve_template_path converts absolute or manager-relative template names to
418// canonical paths. The canonical path is used as the compiled-template cache key.
419fn (m &Manager) resolve_template_path(template_path string) !string {
420 source_path := if os.is_abs_path(template_path) {
421 template_path
422 } else {
423 os.join_path(m.template_dir, template_path)
424 }
425 if !os.exists(source_path) {
426 return error('template "${source_path}" not found')
427 }
428 canonical_path := os.real_path(source_path)
429 ensure_path_inside_template_dir(canonical_path, m.template_dir)!
430 return canonical_path
431}
432
433fn ensure_path_inside_template_dir(path string, template_root string) ! {
434 root := path_without_trailing_separator(template_root)
435 candidate := path_without_trailing_separator(path)
436 if candidate == root || candidate.starts_with(root + os.path_separator) {
437 return
438 }
439 return error('template "${path}" is outside template directory "${template_root}"')
440}
441
442fn path_without_trailing_separator(path string) string {
443 if path.len > os.path_separator.len && path.ends_with(os.path_separator) {
444 return path[..path.len - os.path_separator.len]
445 }
446 return path
447}
448
449// template_type_from_path keeps rendering rules extension-driven and explicit.
450fn template_type_from_path(source_path string, template_extensions map[string]TemplateType) !TemplateType {
451 ext := normalize_template_extension(os.file_ext(source_path))
452 if template_extensions.len > 0 {
453 if template_type := template_extensions[ext] {
454 return template_type
455 }
456 }
457 if template_type := default_template_type_from_extension(ext) {
458 return template_type
459 }
460 return error('template "${source_path}" uses unsupported extension "${ext}"')
461}
462
463fn default_template_type_from_extension(ext string) ?TemplateType {
464 match ext {
465 '.html', '.htm', '.xml' {
466 return .html
467 }
468 '.txt', '.text' {
469 return .text
470 }
471 else {
472 return none
473 }
474 }
475}
476
477// read_template_with_includes expands simple line-level `@include "path"`
478// directives before parsing placeholders. Includes are part of the parsed tree
479// and are tracked as dependencies for reload checks.
480fn read_template_with_includes(source_path string, template_root string, depth int, track_dependencies bool) !(string, []TemplateDependency) {
481 if depth > max_include_depth {
482 return error('maximum @include depth exceeded while reading "${source_path}"')
483 }
484 content := os.read_file(source_path)!
485 mut dependencies := []TemplateDependency{cap: 4}
486 if track_dependencies {
487 dependencies << template_dependency(source_path, content)!
488 }
489 if !content_needs_include_expansion(content) {
490 return content_with_template_newline(content), dependencies
491 }
492 base_dir := os.dir(source_path)
493 mut out := strings.new_builder(content.len)
494 lines := content.split_into_lines()
495 for line in lines {
496 expanded_line, line_dependencies := expand_include_directives(line, base_dir,
497 template_root, depth, track_dependencies)!
498 out.write_string(expanded_line)
499 out.write_u8(`\n`)
500 dependencies << line_dependencies
501 }
502 rendered := out.str()
503 return rendered.clone(), dependencies
504}
505
506fn content_with_template_newline(content string) string {
507 if content == '' || content[content.len - 1] == `\n` {
508 return content.clone()
509 }
510 with_newline := content + '\n'
511 return with_newline.clone()
512}
513
514fn content_needs_include_expansion(content string) bool {
515 for i := 0; i < content.len; i++ {
516 if content[i] == `\r` {
517 return true
518 }
519 if is_include_directive_at(content, i) {
520 return true
521 }
522 }
523 return false
524}
525
526fn is_include_directive_at(content string, pos int) bool {
527 return pos + include_directive.len <= content.len && content[pos] == `@`
528 && content[pos + 1] == `i` && content[pos + 2] == `n` && content[pos + 3] == `c`
529 && content[pos + 4] == `l` && content[pos + 5] == `u` && content[pos + 6] == `d`
530 && content[pos + 7] == `e` && content[pos + 8] == ` `
531}
532
533fn template_dependency(source_path string, content string) !TemplateDependency {
534 stat := os.stat(source_path)!
535 return TemplateDependency{
536 path: source_path.clone()
537 modified_at: stat.mtime
538 size: stat.size
539 content_hash: content_fingerprint(content)
540 }
541}
542
543fn content_fingerprint(content string) u64 {
544 return hash.sum64_string(content, 0)
545}
546
547fn expand_include_directives(line string, base_dir string, template_root string, depth int, track_dependencies bool) !(string, []TemplateDependency) {
548 mut out := strings.new_builder(line.len)
549 mut dependencies := []TemplateDependency{}
550 mut offset := 0
551 for {
552 rel_pos := line[offset..].index(include_directive) or {
553 out.write_string(line[offset..])
554 break
555 }
556 pos := offset + rel_pos
557 target, end_pos := include_target_from_line_at(line, pos) or {
558 out.write_string(line[offset..pos + include_directive.len])
559 offset = pos + include_directive.len
560 continue
561 }
562 out.write_string(line[offset..pos])
563 include_path := resolve_include_path(base_dir, target, template_root)!
564 next_depth := depth + 1
565 included, include_dependencies := read_template_with_includes(include_path, template_root,
566 next_depth, track_dependencies)!
567 out.write_string(included.trim_right('\n'))
568 dependencies << include_dependencies
569 offset = end_pos
570 }
571 expanded := out.str()
572 return expanded.clone(), dependencies
573}
574
575fn include_target_from_line_at(line string, pos int) ?(string, int) {
576 mut cursor := pos + include_directive.len
577 for cursor < line.len && line[cursor].is_space() {
578 cursor++
579 }
580 if cursor >= line.len {
581 return none
582 }
583 quote := line[cursor]
584 if quote != `'` && quote != `"` {
585 return none
586 }
587 start := cursor + 1
588 for cursor = start; cursor < line.len; cursor++ {
589 if line[cursor] == quote {
590 return line[start..cursor], cursor + 1
591 }
592 }
593 return none
594}
595
596fn resolve_include_path(base_dir string, include_target string, template_root string) !string {
597 mut target := include_target
598 if os.file_ext(target) == '' {
599 target += '.html'
600 }
601 source_path := if os.is_abs_path(target) {
602 target
603 } else {
604 os.join_path(base_dir, target)
605 }
606 if !os.exists(source_path) {
607 return error('included template "${source_path}" not found')
608 }
609 include_path := os.real_path(source_path)
610 ensure_path_inside_template_dir(include_path, template_root)!
611 return include_path
612}
613
614// parse_segments converts template content to a compact instruction stream.
615// Each instruction stores a segment kind plus offset/length into `content`.
616// Rendering can then walk the stream without reparsing placeholder syntax.
617fn parse_segments(content string) (string, int, int) {
618 mut instructions := []u8{cap: segment_instruction_size * 16}
619 mut text_start := 0
620 mut estimated_size := 0
621 mut segment_count := 0
622 mut i := 0
623 for i < content.len {
624 if content[i] != `@` || i + 1 >= content.len || !is_placeholder_char(content[i + 1]) {
625 i++
626 continue
627 }
628 if i > text_start {
629 append_segment_instruction(mut instructions, segment_kind_text, text_start,
630 i - text_start)
631 segment_count++
632 estimated_size += i - text_start
633 }
634 mut end := i + 1
635 for end < content.len && is_placeholder_char(content[end]) {
636 end++
637 }
638 append_segment_instruction(mut instructions, segment_kind_placeholder, i + 1, end - i - 1)
639 segment_count++
640 i = end
641 text_start = end
642 }
643 if text_start < content.len {
644 append_segment_instruction(mut instructions, segment_kind_text, text_start,
645 content.len - text_start)
646 segment_count++
647 estimated_size += content.len - text_start
648 }
649 encoded := instructions.bytestr()
650 return encoded.clone(), estimated_size, segment_count
651}
652
653fn append_segment_instruction(mut instructions []u8, kind u8, start int, len int) {
654 instructions << kind
655 append_u32(mut instructions, start)
656 append_u32(mut instructions, len)
657}
658
659fn append_u32(mut data []u8, value int) {
660 encoded := u32(value)
661 data << u8(encoded & 0xff)
662 data << u8((encoded >> 8) & 0xff)
663 data << u8((encoded >> 16) & 0xff)
664 data << u8((encoded >> 24) & 0xff)
665}
666
667fn read_u32(data string, offset int) int {
668 b0 := u32(data[offset])
669 b1 := u32(data[offset + 1]) << 8
670 b2 := u32(data[offset + 2]) << 16
671 b3 := u32(data[offset + 3]) << 24
672 value := b0 | b1 | b2 | b3
673 return int(value)
674}
675
676fn is_placeholder_char(c u8) bool {
677 return (c >= `a` && c <= `z`) || (c >= `A` && c <= `Z`) || (c >= `0` && c <= `9`) || c == `_`
678}
679
680// render_compiled_template walks the parsed instruction stream and writes the
681// final output. Placeholder values are rendered only for this call; the manager
682// never stores caller data after `expand()` returns.
683fn render_compiled_template(compiled &CompiledTemplate, params RenderParams) string {
684 mut out := strings.new_builder(estimate_render_size(compiled))
685 mut offset := 0
686 for offset + segment_instruction_size <= compiled.instructions.len {
687 kind := compiled.instructions[offset]
688 start := read_u32(compiled.instructions, offset + 1)
689 len := read_u32(compiled.instructions, offset + 5)
690 offset += segment_instruction_size
691 if kind == segment_kind_text {
692 text := compiled.segment_text(start, len)
693 out.write_string(text)
694 continue
695 }
696 if kind == segment_kind_placeholder {
697 name := compiled.segment_text(start, len)
698 if write_placeholder_value(mut out, name, params.placeholders, compiled.template_type) {
699 continue
700 }
701 out.write_string(params.missing_placeholder_prefix)
702 out.write_string(name)
703 }
704 }
705 rendered := out.str()
706 return rendered.clone()
707}
708
709fn (compiled &CompiledTemplate) segment_text(start int, len int) string {
710 end := start + len
711 if start < 0 || len < 0 || end > compiled.content.len {
712 return ''
713 }
714 return compiled.content[start..end]
715}
716
717// write_placeholder_value resolves the normal placeholder name first, then the
718// historical `_#includehtml` alias. The alias is accepted for compatibility but
719// still passes through the allow-list based HTML escaping path.
720fn write_placeholder_value(mut out strings.Builder, name string, placeholders &map[string]string, template_type TemplateType) bool {
721 unsafe {
722 if raw_value := placeholders[name] {
723 rendered_value := render_value(raw_value, name.ends_with(include_html_key_suffix),
724 template_type)
725 out.write_string(rendered_value)
726 return true
727 }
728 include_html_name := name + include_html_key_suffix
729 if raw_html := placeholders[include_html_name] {
730 rendered_html := render_value(raw_html, true, template_type)
731 out.write_string(rendered_html)
732 return true
733 }
734 }
735 return false
736}
737
738// estimate_render_size gives strings.Builder enough capacity for common
739// placeholder expansion without making a second pre-render pass.
740fn estimate_render_size(compiled &CompiledTemplate) int {
741 return compiled.estimated_size + 1024 + (compiled.segment_count * 64)
742}
743
744// render_value applies DTM's safety default: values are escaped unless the
745// caller explicitly uses the include-html convention in an HTML template.
746fn render_value(raw string, allow_html bool, template_type TemplateType) string {
747 if allow_html && template_type == .html {
748 return escape_with_allowed_html(raw)
749 }
750 return escape_html(raw)
751}
752
753// escape_with_allowed_html escapes the full value first, then restores only the
754// supported tags. This preserves the legacy opt-in behavior without allowing
755// arbitrary raw HTML through.
756fn escape_with_allowed_html(value string) string {
757 mut escaped := escape_html(value)
758 for tag in allowed_html_tags {
759 escaped = escaped.replace(escape_html(tag), tag)
760 }
761 return escaped
762}
763
764// escape_html is a local deterministic HTML escape helper. It avoids depending
765// on heavier generic replacement paths in the hot render loop.
766fn escape_html(value string) string {
767 mut escaped := strings.new_builder(value.len)
768 for i := 0; i < value.len; i++ {
769 match value[i] {
770 `&` {
771 escaped.write_string('&')
772 }
773 `<` {
774 escaped.write_string('<')
775 }
776 `>` {
777 escaped.write_string('>')
778 }
779 `"` {
780 escaped.write_string('"')
781 }
782 `'` {
783 escaped.write_string(''')
784 }
785 else {
786 escaped.write_u8(value[i])
787 }
788 }
789 }
790 escaped_value := escaped.str()
791 return escaped_value.clone()
792}
793
794// compress_html_output performs a small, predictable whitespace compression
795// pass for HTML templates. It intentionally avoids regex work in the hot path.
796fn compress_html_output(html string) string {
797 mut result := strings.new_builder(html.len)
798 mut pending_space := false
799 mut last_written := u8(0)
800 for i := 0; i < html.len; i++ {
801 c := html[i]
802 if c == `\n` || c == `\t` {
803 continue
804 }
805 if c == ` ` {
806 pending_space = true
807 continue
808 }
809 if pending_space {
810 if !(last_written == `>` && c == `<`) {
811 result.write_u8(` `)
812 last_written = ` `
813 }
814 pending_space = false
815 }
816 result.write_u8(c)
817 last_written = c
818 }
819 compressed := result.str()
820 return compressed.clone()
821}
822