Initial project import

This commit is contained in:
Agila Forge Importer committed 2026-07-13 15:20:32 +00:00
commit 23f2d29e32
11957 files changed
+1754577

No files matched your search

+366
View File
@@ -0,0 +1,366 @@
# dtm2 - Dynamic Template Manager 2
`dtm2` is the modern runtime renderer for Dynamic Template Manager.
It keeps the original DTM idea: templates are normal files on disk, so they can
be edited without recompiling the application.
The main change from `x.templating.dtm` is architectural. `dtm2` caches parsed
template trees, not rendered HTML responses. This keeps rendering fast while
removing the old async rendered-cache server from the hot path.
## Quick Start
Create a `templates/` folder in your application and put your templates inside
it. DTM2 supports `.html`, `.htm`, `.xml`, `.txt`, and `.text` by default.
```v
import x.templating.dtm2
fn main() {
mut manager := dtm2.initialize(
template_dir: 'templates'
)
placeholders := {
'title': 'DTM2'
'body': '<strong>escaped by default</strong>'
}
rendered := manager.expand('page.html', placeholders: &placeholders)
println(rendered)
}
```
Example template:
```html
<!doctype html>
<html>
<head>
<title>@title</title>
</head>
<body>
<main>@body</main>
</body>
</html>
```
The rendered `@body` value is escaped by default.
## Veb Example
```v
import veb
import x.templating.dtm2
pub struct App {
pub mut:
templates &dtm2.Manager = unsafe { nil }
}
pub struct Context {
veb.Context
}
fn main() {
mut app := &App{
templates: dtm2.initialize(
template_dir: 'templates'
)
}
veb.run[App, Context](mut app, 18081)
}
@['/']
pub fn (mut app App) index(mut ctx Context) veb.Result {
placeholders := {
'title': 'Home'
'body': 'Hello from DTM2'
}
html := app.templates.expand('index.html', placeholders: &placeholders)
return ctx.html(html)
}
```
## Available Options
### Manager options
`dtm2.initialize()` accepts:
- `template_dir` (**string**): root directory used for relative template paths.
If empty, `<executable directory>/templates` is used.
- `compress_html` (**bool**): enables a lightweight deterministic HTML
whitespace compressor. It is enabled by default.
- `reload_modified_templates` (**bool**): when enabled, DTM2 checks the source
template and included files before reusing a parsed template tree. It is
enabled by default.
- `extension_config_file` (**string**): optional JSON file containing extension
mappings. If empty, DTM2 automatically loads `dtm2_extensions.json` from the
configured `template_dir` when that file exists.
Example:
```v ignore
mut manager := dtm2.initialize(
template_dir: 'templates'
compress_html: true
reload_modified_templates: true
)
```
For maximum hot-path throughput in applications where templates are immutable
after startup, you can disable reload checks:
```v ignore
mut manager := dtm2.initialize(
template_dir: 'templates'
reload_modified_templates: false
)
```
The recommended runtime model is one long-lived manager per application or
rendering context. Reusing the manager is what keeps parsed templates and path
resolution cached.
### Template extensions
DTM2 has two rendering modes:
- `TemplateType.html`: HTML/XML-like output, with default escaping and optional
HTML compression.
- `TemplateType.text`: raw text output, also escaped by default.
Default mappings:
- HTML mode: `.html`, `.htm`, `.xml`
- Text mode: `.txt`, `.text`
Project-specific extensions should be configured with a JSON file.
Example `templates/dtm2_extensions.json`:
```json
{
"html": [".view", ".tmpl"],
"text": [".mail", ".md"]
}
```
If the file is named `dtm2_extensions.json` and is placed directly in the
configured `template_dir`, DTM2 loads it automatically:
```v ignore
mut manager := dtm2.initialize(
template_dir: 'templates'
)
```
You can also point to an explicit config file:
```v ignore
mut manager := dtm2.initialize(
template_dir: 'templates'
extension_config_file: 'config/my_dtm2_extensions.json'
)
```
DTM2 ships a default config example that can be copied into your own
`templates/` directory:
```sh
vlib/x/templating/dtm2/dtm2_extensions.json
```
If the JSON file is absent, invalid, or contains invalid entries, DTM2 keeps the
built-in defaults and prints a warning for entries that cannot be registered.
JSON extension config files are limited to 64 KB and every extension is
validated before being registered.
### Render options
`manager.expand()` accepts:
- `placeholders` (**&map[string]string**): values inserted in the template.
- `missing_placeholder_prefix` (**string**): prefix written when a placeholder
is missing. The default is `@`, preserving the original placeholder text.
Example:
```v ignore
placeholders := {
'title': 'Example'
}
html := manager.expand('page.html',
placeholders: &placeholders
missing_placeholder_prefix: '@'
)
```
## The Placeholder System
Template placeholders use the `@name` form.
```html
<h1>@title</h1>
<p>@body</p>
```
In DTM2, placeholder values are strings:
```v ignore
placeholders := {
'title': 'Hello'
'count': '42'
}
```
Values are escaped by default in both HTML and text templates.
<u>**Security note for custom non-HTML formats such as SQL:** DTM2 is a template
renderer, not a domain-specific sanitizer. By default, it escapes HTML-special
characters in placeholder values. It does not make SQL queries safe, does not
replace prepared statements, and does not validate business-specific formats.
If you add `.sql` or another sensitive extension through configuration, the
security of that generated content remains the responsibility of the
application.</u>
## Explicit HTML Inclusion
The historical `_#includehtml` suffix is still supported for compatibility.
It allows a placeholder to include a restricted set of HTML tags in `.html`
templates.
```v ignore
placeholders := {
'body_#includehtml': '<p>allowed</p><script>escaped</script>'
}
html := manager.expand('page.html', placeholders: &placeholders)
```
The template still uses the normal placeholder name:
```html
<main>@body</main>
```
DTM2 escapes the complete value first, then restores only allowed tags. This
preserves the old opt-in behavior without allowing arbitrary raw HTML through.
In `.txt` templates, HTML is always escaped.
Allowed tags:
```html
<div>, </div>, <h1>, </h1>, <h2>, </h2>, <h3>, </h3>, <h4>, </h4>,
<h5>, </h5>, <h6>, </h6>, <p>, </p>, <br>, <hr>, <span>, </span>,
<ul>, </ul>, <ol>, </ol>, <li>, </li>, <dl>, </dl>, <dt>, </dt>,
<dd>, </dd>, <menu>, </menu>, <table>, </table>, <caption>, </caption>,
<th>, </th>, <tr>, </tr>, <td>, </td>, <thead>, </thead>,
<thread>, </thread>, <tbody>, </tbody>, <tfoot>, </tfoot>, <col>, </col>,
<colgroup>, </colgroup>, <header>, </header>, <footer>, </footer>,
<main>, </main>, <section>, </section>, <article>, </article>,
<aside>, </aside>, <details>, </details>, <dialog>, </dialog>,
<data>, </data>, <summary>, </summary>
```
## Includes
Templates can include other templates with a simple line-level directive:
```html
<header>@include "partials/nav"</header>
<main>@body</main>
```
Include paths are resolved relative to the current template. If no file
extension is provided, `.html` is added. The final resolved include path must
stay inside the manager `template_dir`; attempts to include files through `../`
or absolute paths outside that root fail.
The same boundary applies to templates passed to `expand()`. Absolute template
paths are accepted only when they resolve inside `template_dir`.
Included files are tracked as dependencies of the parsed template. When
`reload_modified_templates` is enabled, changing an included file invalidates
the cached parsed tree.
## Backward Compatibility With DTM v1
Existing code that imports `x.templating.dtm` is kept source-compatible for the
migration period. The v1 facade is deprecated, but it now delegates rendering to
DTM2 internally.
That means old code can continue to compile:
```v ignore
import x.templating.dtm
mut manager := dtm.initialize()
mut placeholders := map[string]dtm.DtmMultiTypeMap{}
placeholders['title'] = 'Legacy DTM'
placeholders['count'] = 7
html := manager.expand('page.html', placeholders: &placeholders)
```
For new code, prefer importing `x.templating.dtm2` directly:
```v ignore
import x.templating.dtm2
mut manager := dtm2.initialize(template_dir: 'templates')
placeholders := {
'title': 'Modern DTM'
'count': '7'
}
html := manager.expand('page.html', placeholders: &placeholders)
```
Migration notes:
- Replace `import x.templating.dtm` with `import x.templating.dtm2`.
- Replace `DtmMultiTypeMap` placeholder maps with `map[string]string`.
- Convert numeric values to strings before rendering.
- Remove `stop_cache_handler()` calls; DTM2 does not start an async cache
server.
- Keep using `_#includehtml` only when HTML inclusion is intentional.
## Design Notes
DTM2 intentionally keeps rendering and rendered-output caching separate.
The manager caches:
- canonical template paths;
- parsed template trees;
- dependency metadata for root templates and includes.
The manager does not cache rendered HTML responses. If a future rendered-cache
layer is needed, it should remain a small optional layer above DTM2 rather than
part of the parser/renderer core.
## Benchmarks
The local benchmark harness lives in:
```sh
vlib/x/templating/dtm2/benchmarks/
```
Run it from the repository root:
```sh
vlib/x/templating/dtm2/benchmarks/run_dtm2_benchmark.sh
```
Useful options:
- `DTM2_BENCH_MODE=prod|prod_o2|dev`
- `DTM2_BENCH_CASE=all|small_hot|small_cold|many_hot|many_cold|include_hot`
- `DTM2_BENCH_CASE=include_cold|xml_hot|xml_cold`
- `DTM2_BENCH_ITERATIONS=50000`
- `DTM2_BENCH_COLD_ITERATIONS=500`
- `DTM2_BENCH_PLACEHOLDERS=50`
- `DTM2_BENCH_COMPRESS_HTML=true`
- `DTM2_BENCH_RELOAD_MODIFIED_TEMPLATES=false`
- `DTM2_BENCH_VALIDATE_EACH_ITERATION=false`
Benchmark result directories are generated locally under
`vlib/x/templating/dtm2/benchmarks/results/` and should not be committed.
@@ -0,0 +1,212 @@
module main
import os
import strings
import time
import x.templating.dtm2
const bench_root_name = 'dtm2_benchmark'
struct BenchConfig {
case_name string
iterations int
cold_iterations int
placeholder_count int
compress_html bool
reload_modified_templates bool
validate_each_iteration bool
}
fn main() {
config := BenchConfig{
case_name: os.getenv('DTM2_BENCH_CASE')
iterations: env_int('DTM2_BENCH_ITERATIONS', 50000)
cold_iterations: env_int('DTM2_BENCH_COLD_ITERATIONS', 500)
placeholder_count: env_int('DTM2_BENCH_PLACEHOLDERS', 50)
compress_html: env_bool('DTM2_BENCH_COMPRESS_HTML', true)
reload_modified_templates: env_bool('DTM2_BENCH_RELOAD_MODIFIED_TEMPLATES', false)
validate_each_iteration: env_bool('DTM2_BENCH_VALIDATE_EACH_ITERATION', false)
}
root := os.join_path(os.vtmp_dir(), '${bench_root_name}_${os.getpid()}')
os.rmdir_all(root) or {}
setup_files(root, config)!
defer {
os.rmdir_all(root) or {}
}
print_config(root, config)
small := small_placeholders()
many := many_placeholders(config.placeholder_count)
include := include_placeholders()
if should_run(config, 'small_hot') {
bench_hot('small_hot', root, 'small.html', small, config.iterations, config)
}
if should_run(config, 'small_cold') {
bench_cold('small_cold', root, 'small.html', small, config.cold_iterations, config)
}
if should_run(config, 'many_hot') {
bench_hot('many_hot', root, 'many.html', many, config.iterations, config)
}
if should_run(config, 'many_cold') {
bench_cold('many_cold', root, 'many.html', many, config.cold_iterations, config)
}
if should_run(config, 'include_hot') {
bench_hot('include_hot', root, 'with_include.html', include, config.iterations, config)
}
if should_run(config, 'include_cold') {
bench_cold('include_cold', root, 'with_include.html', include, config.cold_iterations,
config)
}
if should_run(config, 'xml_hot') {
bench_hot('xml_hot', root, 'feed.xml', small, config.iterations, config)
}
if should_run(config, 'xml_cold') {
bench_cold('xml_cold', root, 'feed.xml', small, config.cold_iterations, config)
}
}
fn print_config(root string, config BenchConfig) {
println('DTM2 benchmark')
println('root: ${root}')
println('case_name: ${if config.case_name == '' { 'all' } else { config.case_name }}')
println('iterations: ${config.iterations}')
println('cold_iterations: ${config.cold_iterations}')
println('placeholder_count: ${config.placeholder_count}')
println('compress_html: ${config.compress_html}')
println('reload_modified_templates: ${config.reload_modified_templates}')
println('validate_each_iteration: ${config.validate_each_iteration}')
println('')
}
fn env_int(name string, default_value int) int {
raw := os.getenv(name)
if raw == '' {
return default_value
}
value := raw.int()
if value <= 0 {
return default_value
}
return value
}
fn env_bool(name string, default_value bool) bool {
raw := os.getenv(name).to_lower()
if raw == '' {
return default_value
}
if raw in ['1', 'true', 'yes', 'on'] {
return true
}
if raw in ['0', 'false', 'no', 'off'] {
return false
}
return default_value
}
fn should_run(config BenchConfig, label string) bool {
return config.case_name == '' || config.case_name == 'all' || config.case_name == label
}
fn setup_files(root string, config BenchConfig) ! {
os.mkdir_all(os.join_path(root, 'partials'))!
os.write_file(os.join_path(root, 'small.html'),
'<main>@title <p>@body</p><span>@count</span></main>')!
mut many := strings.new_builder(config.placeholder_count * 30)
many.writeln('<dl>')
for i := 0; i < config.placeholder_count; i++ {
many.writeln('<dt>${i}</dt><dd>@p_${i}</dd>')
}
many.writeln('</dl>')
os.write_file(os.join_path(root, 'many.html'), many.str())!
os.write_file(os.join_path(root, 'partials', 'nav.html'), '<nav>@title</nav>')!
os.write_file(os.join_path(root, 'with_include.html'),
'<header>@include "partials/nav"</header><main>@body</main>')!
os.write_file(os.join_path(root, 'feed.xml'),
'<feed><title>@title</title><entry>@body</entry><count>@count</count></feed>')!
}
fn small_placeholders() map[string]string {
mut placeholders := map[string]string{}
placeholders['title'] = 'Small'.clone()
placeholders['body'] = 'Body <escaped>'.clone()
placeholders['count'] = '42'
return placeholders
}
fn many_placeholders(count int) map[string]string {
mut placeholders := map[string]string{}
for i := 0; i < count; i++ {
placeholders['p_${i}'] = 'value ${i}'.clone()
}
return placeholders
}
fn include_placeholders() map[string]string {
mut placeholders := map[string]string{}
placeholders['title'] = 'Menu'.clone()
placeholders['body'] = 'Body <escaped>'.clone()
return placeholders
}
fn bench_hot(label string, root string, template_path string, placeholders map[string]string, iterations int, config BenchConfig) {
mut manager := new_manager(root, config)
expected := manager.expand(template_path, placeholders: &placeholders).clone()
mut last_len := expected.len
sw := time.new_stopwatch()
for i in 0 .. iterations {
rendered := manager.expand(template_path, placeholders: &placeholders)
if config.validate_each_iteration {
validate_rendered(label, i, expected, rendered)
}
last_len = rendered.len
}
elapsed := sw.elapsed()
validate_rendered(label, iterations, expected, manager.expand(template_path,
placeholders: &placeholders
))
print_result(label, iterations, elapsed, last_len, manager.compiled_template_count())
}
fn bench_cold(label string, root string, template_path string, placeholders map[string]string, iterations int, config BenchConfig) {
mut expected_manager := new_manager(root, config)
expected := expected_manager.expand(template_path, placeholders: &placeholders).clone()
mut last_len := expected.len
sw := time.new_stopwatch()
for i in 0 .. iterations {
mut manager := new_manager(root, config)
rendered := manager.expand(template_path, placeholders: &placeholders)
if config.validate_each_iteration {
validate_rendered(label, i, expected, rendered)
}
last_len = rendered.len
}
elapsed := sw.elapsed()
mut final_manager := new_manager(root, config)
validate_rendered(label, iterations, expected, final_manager.expand(template_path,
placeholders: &placeholders
))
print_result(label, iterations, elapsed, last_len, final_manager.compiled_template_count())
}
fn new_manager(root string, config BenchConfig) &dtm2.Manager {
return dtm2.initialize(
template_dir: root
compress_html: config.compress_html
reload_modified_templates: config.reload_modified_templates
)
}
fn validate_rendered(label string, iteration int, expected string, rendered string) {
if rendered != expected {
eprintln('${label} invalid output at iteration ${iteration}: expected_len=${expected.len} actual_len=${rendered.len}')
exit(1)
}
}
fn print_result(label string, iterations int, elapsed time.Duration, last_len int, compiled_count int) {
ns_per_op := if iterations > 0 { elapsed.nanoseconds() / i64(iterations) } else { i64(0) }
ops_per_sec := if ns_per_op > 0 { 1_000_000_000.0 / f64(ns_per_op) } else { 0.0 }
println('${label:-16} iterations=${iterations:8} total_ms=${elapsed.milliseconds():8} ns_per_op=${ns_per_op:10} ops_per_sec=${ops_per_sec:10.1f} last_len=${last_len:6} compiled_templates=${compiled_count}')
}
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "${script_dir}/../../../../.." && pwd)"
cd "${repo_root}"
timestamp="$(date +%Y-%m-%d_%H%M%S)"
results_dir="${DTM2_BENCH_RESULTS_DIR:-vlib/x/templating/dtm2/benchmarks/results/${timestamp}}"
bench_source="vlib/x/templating/dtm2/benchmarks/dtm2_benchmark.v"
bench_bin="/tmp/dtm2_benchmark_${timestamp}_$$"
bench_mode="${DTM2_BENCH_MODE:-prod}"
mkdir -p "${results_dir}"
trap 'rm -f "${bench_bin}"' EXIT
case "${bench_mode}" in
dev)
build_cmd=(./v -o "${bench_bin}" "${bench_source}")
;;
prod)
build_cmd=(./v -prod -o "${bench_bin}" "${bench_source}")
;;
prod_o2)
build_cmd=(./v -prod -no-prod-options -cflags -O2 -o "${bench_bin}" "${bench_source}")
;;
*)
echo "unsupported DTM2_BENCH_MODE=${bench_mode}; use dev, prod, or prod_o2" >&2
exit 2
;;
esac
{
echo "date: $(date -Iseconds)"
echo "v_version: $(./v --version)"
echo "kernel: $(uname -srmo)"
echo "cpu_model: $(awk -F': ' '/model name/{print $2; exit}' /proc/cpuinfo)"
echo "logical_cpu_count: $(nproc)"
echo "memory_total: $(awk '/MemTotal/{print $2 " " $3}' /proc/meminfo)"
echo "benchmark_mode: ${bench_mode}"
echo "benchmark_source: ${bench_source}"
echo "DTM2_BENCH_CASE: ${DTM2_BENCH_CASE:-all}"
echo "DTM2_BENCH_ITERATIONS: ${DTM2_BENCH_ITERATIONS:-50000}"
echo "DTM2_BENCH_COLD_ITERATIONS: ${DTM2_BENCH_COLD_ITERATIONS:-500}"
echo "DTM2_BENCH_PLACEHOLDERS: ${DTM2_BENCH_PLACEHOLDERS:-50}"
echo "DTM2_BENCH_COMPRESS_HTML: ${DTM2_BENCH_COMPRESS_HTML:-true}"
echo "DTM2_BENCH_RELOAD_MODIFIED_TEMPLATES: ${DTM2_BENCH_RELOAD_MODIFIED_TEMPLATES:-false}"
echo "DTM2_BENCH_VALIDATE_EACH_ITERATION: ${DTM2_BENCH_VALIDATE_EACH_ITERATION:-false}"
} > "${results_dir}/environment.txt"
"${build_cmd[@]}" > "${results_dir}/build.log" 2>&1
{
cat "${results_dir}/environment.txt"
echo
/usr/bin/time -v "${bench_bin}"
} 2>&1 | tee "${results_dir}/benchmark.log"
@@ -0,0 +1,4 @@
{
"html": [".html", ".htm", ".xml"],
"text": [".txt", ".text"]
}
@@ -0,0 +1,821 @@
module dtm2
import hash
import json
import os
import strings
// dtm2 is the modern runtime renderer for Dynamic Template Manager.
// It keeps DTM dynamic, meaning templates can still be edited on disk without
// recompiling the application, while moving the hot path to a parsed-template
// cache instead of a rendered-output cache. File extensions are configurable,
// but every template ultimately renders in either HTML mode or text mode.
//
// The intended runtime model is one long-lived Manager per application or
// rendering context. Tests and benchmarks may create short-lived managers, but
// normal applications should reuse a manager so parsed templates and resolved
// paths stay hot.
const message_signature = '[Dynamic Template Manager 2]'
const internal_server_error = 'Internal Server Error'
const include_html_key_suffix = '_#includehtml'
const include_directive = '@include '
const max_include_depth = 32
const max_extension_config_size = 64 * 1024
const default_extension_config_filename = 'dtm2_extensions.json'
const segment_instruction_size = 9
const segment_kind_text = u8(0)
const segment_kind_placeholder = u8(1)
// These tags are the compatibility allow-list for the historical `_#includehtml`
// placeholder suffix. Any other tag is still escaped.
const allowed_html_tags = ['<div>', '</div>', '<h1>', '</h1>', '<h2>', '</h2>', '<h3>', '</h3>',
'<h4>', '</h4>', '<h5>', '</h5>', '<h6>', '</h6>', '<p>', '</p>', '<br>', '<hr>', '<span>',
'</span>', '<ul>', '</ul>', '<ol>', '</ol>', '<li>', '</li>', '<dl>', '</dl>', '<dt>', '</dt>',
'<dd>', '</dd>', '<menu>', '</menu>', '<table>', '</table>', '<caption>', '</caption>', '<th>',
'</th>', '<tr>', '</tr>', '<td>', '</td>', '<thead>', '</thead>', '<thread>', '</thread>',
'<tbody>', '</tbody>', '<tfoot>', '</tfoot>', '<col>', '</col>', '<colgroup>', '</colgroup>',
'<header>', '</header>', '<footer>', '</footer>', '<main>', '</main>', '<section>', '</section>',
'<article>', '</article>', '<aside>', '</aside>', '<details>', '</details>', '<dialog>',
'</dialog>', '<data>', '</data>', '<summary>', '</summary>']!
pub enum TemplateType {
html
text
}
// ExtensionConfig is the JSON representation accepted by
// `ManagerParams.extension_config_file`.
//
// Example:
//
// ```json
// {
// "html": [".html", ".htm", ".xml", ".view"],
// "text": [".txt", ".mail"]
// }
// ```
pub struct ExtensionConfig {
pub:
html []string
text []string
}
// TemplateDependency records the file metadata that decides whether a parsed
// template is still fresh. Includes are tracked as dependencies of the parent
// template, so editing a partial can invalidate the compiled parent tree.
struct TemplateDependency {
path string
modified_at i64
size u64
content_hash u64
}
// CompiledTemplate is the parsed representation stored by Manager.
//
// It intentionally stores a compact instruction string instead of slices of
// segment structs. The instructions contain offsets into the owned `content`
// string, which keeps the cache compact and avoids keeping fragile string-slice
// graphs alive across many -prod renders.
@[heap]
struct CompiledTemplate {
// Rendering mode inferred from the source file extension.
template_type TemplateType
// Full template content after static @include expansion.
content string
// Compact metadata used to decide whether this compiled tree is stale.
dependency_signature string
// Binary instruction stream: one fixed-size record per text/placeholder segment.
instructions string
// Number of text and placeholder records in `instructions`.
segment_count int
// Static-text size hint used to preallocate the render buffer.
estimated_size int
}
// Manager owns the runtime state of a dtm2 renderer.
//
// It caches resolved template paths and parsed template trees, but never caches
// rendered HTML responses. This keeps the new engine deterministic and leaves
// legacy rendered-cache compatibility in `x.templating.dtm`.
@[heap]
pub struct Manager {
mut:
// Base directory for relative template paths.
template_dir string
// Enables the lightweight deterministic HTML whitespace compressor.
compress_html bool
// When true, source and include files are stat-checked before cache reuse.
reload_modified_templates bool
// Maps caller-provided template paths to canonical source paths.
resolved_template_paths map[string]string
// Parsed-template cache keyed by canonical source path.
compiled_templates map[string]&CompiledTemplate
// User-provided extension-to-render-mode overrides. Built-in extensions are
// resolved without allocating a per-manager default map.
template_extensions map[string]TemplateType
}
// ManagerParams configure a dtm2 Manager.
@[params]
pub struct ManagerParams {
pub:
// Root directory used when `expand()` receives a relative template path.
// If empty, `<executable directory>/templates` is used.
template_dir string
// Compresses HTML output by removing newlines/tabs and redundant spaces.
compress_html bool = true
// Re-check source files and includes before reusing a parsed template tree.
// Disable it for maximum hot-path throughput when templates are immutable.
reload_modified_templates bool = true
// Additional or overriding extension mappings.
// Default mappings are: `.html`, `.htm`, `.xml` => HTML mode and
// `.txt`, `.text` => text mode.
template_extensions map[string]TemplateType
// Optional JSON file containing extension mappings. It is merged after the
// default mappings and before `template_extensions`, so explicit code
// configuration wins over the file. If empty, DTM2 tries to load
// `<template_dir>/dtm2_extensions.json` when that file exists.
extension_config_file string
}
// RenderParams configure one render call.
@[params]
pub struct RenderParams {
pub:
// Placeholder values keyed by their template name without the `@` prefix.
// Values are escaped by default.
placeholders &map[string]string = &map[string]string{}
// Prefix written back when a placeholder is missing. The default preserves
// the original `@placeholder` text.
missing_placeholder_prefix string = '@'
}
// initialize creates a dynamic template manager rooted at `params.template_dir`.
//
// The returned manager should normally be kept and reused. Reusing it is what
// gives dtm2 its parsed-template and path-resolution cache benefits.
pub fn initialize(params ManagerParams) &Manager {
raw_template_dir := if params.template_dir == '' {
os.join_path(os.dir(os.executable()), 'templates')
} else {
params.template_dir
}
template_dir := canonical_template_dir(raw_template_dir)
template_extensions := build_template_extensions(params, template_dir)
return &Manager{
template_dir: template_dir.clone()
compress_html: params.compress_html
reload_modified_templates: params.reload_modified_templates
resolved_template_paths: map[string]string{}
compiled_templates: map[string]&CompiledTemplate{}
template_extensions: template_extensions
}
}
fn canonical_template_dir(template_dir string) string {
return os.real_path(template_dir)
}
fn build_template_extensions(params ManagerParams, template_dir string) map[string]TemplateType {
mut extensions := map[string]TemplateType{}
config_path := extension_config_path(params, template_dir)
if config_path != '' {
file_extensions := read_extension_config_file(config_path) or {
eprintln('${message_signature} ${err.msg()}')
map[string]TemplateType{}
}
merge_template_extensions(mut extensions, file_extensions)
}
merge_template_extensions(mut extensions, params.template_extensions)
return extensions
}
fn extension_config_path(params ManagerParams, template_dir string) string {
if params.extension_config_file != '' {
return params.extension_config_file
}
default_path := os.join_path(template_dir, default_extension_config_filename)
if os.exists(default_path) {
return default_path
}
return ''
}
fn read_extension_config_file(config_path string) !map[string]TemplateType {
if !os.exists(config_path) {
return error('extension config file "${config_path}" not found')
}
if os.is_dir(config_path) {
return error('extension config path "${config_path}" is a directory')
}
config_stat := os.stat(config_path)!
if config_stat.size > u64(max_extension_config_size) {
return error('extension config file "${config_path}" is larger than ${max_extension_config_size} bytes')
}
raw_config := os.read_file(config_path)!
config := json.decode(ExtensionConfig, raw_config)!
mut extensions := map[string]TemplateType{}
merge_template_extension_list(mut extensions, config.html, .html)
merge_template_extension_list(mut extensions, config.text, .text)
return extensions
}
fn merge_template_extensions(mut target map[string]TemplateType, source map[string]TemplateType) {
for ext, template_type in source {
normalized := validate_template_extension(ext) or {
eprintln('${message_signature} ${err.msg()}')
continue
}
target[normalized] = template_type
}
}
fn merge_template_extension_list(mut target map[string]TemplateType, extensions []string, template_type TemplateType) {
for ext in extensions {
normalized := validate_template_extension(ext) or {
eprintln('${message_signature} ${err.msg()}')
continue
}
target[normalized] = template_type
}
}
fn normalize_template_extension(ext string) string {
trimmed := ext.trim_space().to_lower()
if trimmed == '' {
return ''
}
if trimmed.starts_with('.') {
return trimmed
}
return '.${trimmed}'
}
fn validate_template_extension(ext string) !string {
normalized := normalize_template_extension(ext)
if normalized.len < 2 {
return error('ignoring invalid template extension "${ext}"')
}
for i := 1; i < normalized.len; i++ {
c := normalized[i]
if (c >= `a` && c <= `z`) || (c >= `0` && c <= `9`) || c == `_` || c == `-` || c == `.` {
continue
}
return error('ignoring invalid template extension "${ext}"')
}
return normalized
}
// compiled_template_count is intentionally exposed for tests and diagnostics. In
// dtm2 it counts parsed template trees currently held by the manager.
pub fn (m &Manager) compiled_template_count() int {
return m.compiled_templates.len
}
// expand renders `template_path` with the provided placeholders.
//
// `template_path` can be absolute or relative to the manager's template
// directory. Supported extensions come from the manager extension table.
// Errors are reported to stderr and return the legacy-compatible
// `Internal Server Error` string.
pub fn (mut m Manager) expand(template_path string, params RenderParams) string {
source_path := m.cached_template_path(template_path) or {
eprintln('${message_signature} ${err.msg()}')
return internal_server_error
}
compiled := m.compiled_template_for_path(source_path) or {
eprintln('${message_signature} ${err.msg()}')
return internal_server_error
}
if m.compress_html && compiled.template_type == .html {
rendered := render_compiled_template(compiled, params)
compressed := compress_html_output(rendered)
return compressed.clone()
}
rendered := render_compiled_template(compiled, params)
return rendered.clone()
}
// cached_template_path reuses canonical paths when reload checks are disabled.
// When reload checks are enabled, it revalidates the current real path so a
// replaced symlink cannot bypass the template directory boundary.
fn (mut m Manager) cached_template_path(template_path string) !string {
if source_path := m.resolved_template_paths[template_path] {
if m.reload_modified_templates {
current_source_path := m.resolve_template_path(template_path)!
if current_source_path != source_path {
m.resolved_template_paths[template_path] = current_source_path
return current_source_path
}
}
return source_path
}
source_path := m.resolve_template_path(template_path)!
m.resolved_template_paths[template_path] = source_path
return source_path
}
// compiled_template_for_path returns the parsed tree for a canonical source
// path. When reload checks are enabled, dependency metadata decides whether the
// tree can be reused or must be rebuilt from disk.
fn (mut m Manager) compiled_template_for_path(source_path string) !&CompiledTemplate {
if compiled := m.compiled_templates[source_path] {
if !m.reload_modified_templates || compiled.dependencies_are_fresh(m.template_dir) {
return compiled
}
}
compiled := compile_template_from_file(source_path, m.template_dir, m.template_extensions,
m.reload_modified_templates)!
m.compiled_templates[source_path] = compiled
return compiled
}
// compile_template_from_file reads a template, expands static includes, parses
// placeholders, and stores dependency metadata for future invalidation.
fn compile_template_from_file(source_path string, template_root string, template_extensions map[string]TemplateType, track_dependencies bool) !&CompiledTemplate {
canonical_path := os.real_path(source_path)
ensure_path_inside_template_dir(canonical_path, template_root)!
template_type := template_type_from_path(canonical_path, template_extensions)!
content, dependencies := read_template_with_includes(canonical_path, template_root, 0,
track_dependencies)!
instructions, estimated_size, segment_count := parse_segments(content)
dependency_signature := encode_dependencies(dependencies)
return &CompiledTemplate{
template_type: template_type
content: copy_string(content)
dependency_signature: copy_string(dependency_signature)
instructions: copy_string(instructions)
segment_count: segment_count
estimated_size: estimated_size
}
}
// copy_string forces cached strings to own their memory. Cached templates live
// beyond the stack frame that built them, so the cache should not retain
// accidental views into temporary buffers.
fn copy_string(value string) string {
mut bytes := []u8{len: value.len}
for i := 0; i < value.len; i++ {
bytes[i] = value[i]
}
copied := bytes.bytestr()
return copied.clone()
}
// dependencies_are_fresh checks root and included files. The content hash closes
// the same-second, same-size edit window left by second-resolution mtimes.
fn (compiled &CompiledTemplate) dependencies_are_fresh(template_root string) bool {
signature := compiled.dependency_signature
mut offset := 0
for offset < signature.len {
first_sep := index_byte_from(signature, `|`, offset) or { return false }
second_sep := index_byte_from(signature, `|`, first_sep + 1) or { return false }
third_sep := index_byte_from(signature, `|`, second_sep + 1) or { return false }
line_end := index_byte_from(signature, `\n`, third_sep + 1) or { signature.len }
size := signature[first_sep + 1..second_sep].i64()
if size < 0 {
return false
}
content_hash := signature[second_sep + 1..third_sep].u64()
path := signature[third_sep + 1..line_end]
current_path := os.real_path(path)
ensure_path_inside_template_dir(current_path, template_root) or { return false }
if current_path != path {
return false
}
stat := os.stat(path) or { return false }
if stat.mtime != signature[offset..first_sep].i64() || stat.size != u64(size) {
return false
}
content := os.read_file(path) or { return false }
if content_fingerprint(content) != content_hash {
return false
}
offset = line_end + 1
}
return true
}
fn index_byte_from(value string, needle u8, start int) ?int {
for i := start; i < value.len; i++ {
if value[i] == needle {
return i
}
}
return none
}
fn encode_dependencies(dependencies []TemplateDependency) string {
mut out := strings.new_builder(dependencies.len * 64)
for dependency in dependencies {
out.writeln('${dependency.modified_at}|${dependency.size}|${dependency.content_hash}|${dependency.path}')
}
encoded := out.str()
return encoded.clone()
}
// resolve_template_path converts absolute or manager-relative template names to
// canonical paths. The canonical path is used as the compiled-template cache key.
fn (m &Manager) resolve_template_path(template_path string) !string {
source_path := if os.is_abs_path(template_path) {
template_path
} else {
os.join_path(m.template_dir, template_path)
}
if !os.exists(source_path) {
return error('template "${source_path}" not found')
}
canonical_path := os.real_path(source_path)
ensure_path_inside_template_dir(canonical_path, m.template_dir)!
return canonical_path
}
fn ensure_path_inside_template_dir(path string, template_root string) ! {
root := path_without_trailing_separator(template_root)
candidate := path_without_trailing_separator(path)
if candidate == root || candidate.starts_with(root + os.path_separator) {
return
}
return error('template "${path}" is outside template directory "${template_root}"')
}
fn path_without_trailing_separator(path string) string {
if path.len > os.path_separator.len && path.ends_with(os.path_separator) {
return path[..path.len - os.path_separator.len]
}
return path
}
// template_type_from_path keeps rendering rules extension-driven and explicit.
fn template_type_from_path(source_path string, template_extensions map[string]TemplateType) !TemplateType {
ext := normalize_template_extension(os.file_ext(source_path))
if template_extensions.len > 0 {
if template_type := template_extensions[ext] {
return template_type
}
}
if template_type := default_template_type_from_extension(ext) {
return template_type
}
return error('template "${source_path}" uses unsupported extension "${ext}"')
}
fn default_template_type_from_extension(ext string) ?TemplateType {
match ext {
'.html', '.htm', '.xml' {
return .html
}
'.txt', '.text' {
return .text
}
else {
return none
}
}
}
// read_template_with_includes expands simple line-level `@include "path"`
// directives before parsing placeholders. Includes are part of the parsed tree
// and are tracked as dependencies for reload checks.
fn read_template_with_includes(source_path string, template_root string, depth int, track_dependencies bool) !(string, []TemplateDependency) {
if depth > max_include_depth {
return error('maximum @include depth exceeded while reading "${source_path}"')
}
content := os.read_file(source_path)!
mut dependencies := []TemplateDependency{cap: 4}
if track_dependencies {
dependencies << template_dependency(source_path, content)!
}
if !content_needs_include_expansion(content) {
return content_with_template_newline(content), dependencies
}
base_dir := os.dir(source_path)
mut out := strings.new_builder(content.len)
lines := content.split_into_lines()
for line in lines {
expanded_line, line_dependencies := expand_include_directives(line, base_dir,
template_root, depth, track_dependencies)!
out.write_string(expanded_line)
out.write_u8(`\n`)
dependencies << line_dependencies
}
rendered := out.str()
return rendered.clone(), dependencies
}
fn content_with_template_newline(content string) string {
if content == '' || content[content.len - 1] == `\n` {
return content.clone()
}
with_newline := content + '\n'
return with_newline.clone()
}
fn content_needs_include_expansion(content string) bool {
for i := 0; i < content.len; i++ {
if content[i] == `\r` {
return true
}
if is_include_directive_at(content, i) {
return true
}
}
return false
}
fn is_include_directive_at(content string, pos int) bool {
return pos + include_directive.len <= content.len && content[pos] == `@`
&& content[pos + 1] == `i` && content[pos + 2] == `n` && content[pos + 3] == `c`
&& content[pos + 4] == `l` && content[pos + 5] == `u` && content[pos + 6] == `d`
&& content[pos + 7] == `e` && content[pos + 8] == ` `
}
fn template_dependency(source_path string, content string) !TemplateDependency {
stat := os.stat(source_path)!
return TemplateDependency{
path: source_path.clone()
modified_at: stat.mtime
size: stat.size
content_hash: content_fingerprint(content)
}
}
fn content_fingerprint(content string) u64 {
return hash.sum64_string(content, 0)
}
fn expand_include_directives(line string, base_dir string, template_root string, depth int, track_dependencies bool) !(string, []TemplateDependency) {
mut out := strings.new_builder(line.len)
mut dependencies := []TemplateDependency{}
mut offset := 0
for {
rel_pos := line[offset..].index(include_directive) or {
out.write_string(line[offset..])
break
}
pos := offset + rel_pos
target, end_pos := include_target_from_line_at(line, pos) or {
out.write_string(line[offset..pos + include_directive.len])
offset = pos + include_directive.len
continue
}
out.write_string(line[offset..pos])
include_path := resolve_include_path(base_dir, target, template_root)!
next_depth := depth + 1
included, include_dependencies := read_template_with_includes(include_path, template_root,
next_depth, track_dependencies)!
out.write_string(included.trim_right('\n'))
dependencies << include_dependencies
offset = end_pos
}
expanded := out.str()
return expanded.clone(), dependencies
}
fn include_target_from_line_at(line string, pos int) ?(string, int) {
mut cursor := pos + include_directive.len
for cursor < line.len && line[cursor].is_space() {
cursor++
}
if cursor >= line.len {
return none
}
quote := line[cursor]
if quote != `'` && quote != `"` {
return none
}
start := cursor + 1
for cursor = start; cursor < line.len; cursor++ {
if line[cursor] == quote {
return line[start..cursor], cursor + 1
}
}
return none
}
fn resolve_include_path(base_dir string, include_target string, template_root string) !string {
mut target := include_target
if os.file_ext(target) == '' {
target += '.html'
}
source_path := if os.is_abs_path(target) {
target
} else {
os.join_path(base_dir, target)
}
if !os.exists(source_path) {
return error('included template "${source_path}" not found')
}
include_path := os.real_path(source_path)
ensure_path_inside_template_dir(include_path, template_root)!
return include_path
}
// parse_segments converts template content to a compact instruction stream.
// Each instruction stores a segment kind plus offset/length into `content`.
// Rendering can then walk the stream without reparsing placeholder syntax.
fn parse_segments(content string) (string, int, int) {
mut instructions := []u8{cap: segment_instruction_size * 16}
mut text_start := 0
mut estimated_size := 0
mut segment_count := 0
mut i := 0
for i < content.len {
if content[i] != `@` || i + 1 >= content.len || !is_placeholder_char(content[i + 1]) {
i++
continue
}
if i > text_start {
append_segment_instruction(mut instructions, segment_kind_text, text_start,
i - text_start)
segment_count++
estimated_size += i - text_start
}
mut end := i + 1
for end < content.len && is_placeholder_char(content[end]) {
end++
}
append_segment_instruction(mut instructions, segment_kind_placeholder, i + 1, end - i - 1)
segment_count++
i = end
text_start = end
}
if text_start < content.len {
append_segment_instruction(mut instructions, segment_kind_text, text_start,
content.len - text_start)
segment_count++
estimated_size += content.len - text_start
}
encoded := instructions.bytestr()
return encoded.clone(), estimated_size, segment_count
}
fn append_segment_instruction(mut instructions []u8, kind u8, start int, len int) {
instructions << kind
append_u32(mut instructions, start)
append_u32(mut instructions, len)
}
fn append_u32(mut data []u8, value int) {
encoded := u32(value)
data << u8(encoded & 0xff)
data << u8((encoded >> 8) & 0xff)
data << u8((encoded >> 16) & 0xff)
data << u8((encoded >> 24) & 0xff)
}
fn read_u32(data string, offset int) int {
b0 := u32(data[offset])
b1 := u32(data[offset + 1]) << 8
b2 := u32(data[offset + 2]) << 16
b3 := u32(data[offset + 3]) << 24
value := b0 | b1 | b2 | b3
return int(value)
}
fn is_placeholder_char(c u8) bool {
return (c >= `a` && c <= `z`) || (c >= `A` && c <= `Z`) || (c >= `0` && c <= `9`) || c == `_`
}
// render_compiled_template walks the parsed instruction stream and writes the
// final output. Placeholder values are rendered only for this call; the manager
// never stores caller data after `expand()` returns.
fn render_compiled_template(compiled &CompiledTemplate, params RenderParams) string {
mut out := strings.new_builder(estimate_render_size(compiled))
mut offset := 0
for offset + segment_instruction_size <= compiled.instructions.len {
kind := compiled.instructions[offset]
start := read_u32(compiled.instructions, offset + 1)
len := read_u32(compiled.instructions, offset + 5)
offset += segment_instruction_size
if kind == segment_kind_text {
text := compiled.segment_text(start, len)
out.write_string(text)
continue
}
if kind == segment_kind_placeholder {
name := compiled.segment_text(start, len)
if write_placeholder_value(mut out, name, params.placeholders, compiled.template_type) {
continue
}
out.write_string(params.missing_placeholder_prefix)
out.write_string(name)
}
}
rendered := out.str()
return rendered.clone()
}
fn (compiled &CompiledTemplate) segment_text(start int, len int) string {
end := start + len
if start < 0 || len < 0 || end > compiled.content.len {
return ''
}
return compiled.content[start..end]
}
// write_placeholder_value resolves the normal placeholder name first, then the
// historical `_#includehtml` alias. The alias is accepted for compatibility but
// still passes through the allow-list based HTML escaping path.
fn write_placeholder_value(mut out strings.Builder, name string, placeholders &map[string]string, template_type TemplateType) bool {
unsafe {
if raw_value := placeholders[name] {
rendered_value := render_value(raw_value, name.ends_with(include_html_key_suffix),
template_type)
out.write_string(rendered_value)
return true
}
include_html_name := name + include_html_key_suffix
if raw_html := placeholders[include_html_name] {
rendered_html := render_value(raw_html, true, template_type)
out.write_string(rendered_html)
return true
}
}
return false
}
// estimate_render_size gives strings.Builder enough capacity for common
// placeholder expansion without making a second pre-render pass.
fn estimate_render_size(compiled &CompiledTemplate) int {
return compiled.estimated_size + 1024 + (compiled.segment_count * 64)
}
// render_value applies DTM's safety default: values are escaped unless the
// caller explicitly uses the include-html convention in an HTML template.
fn render_value(raw string, allow_html bool, template_type TemplateType) string {
if allow_html && template_type == .html {
return escape_with_allowed_html(raw)
}
return escape_html(raw)
}
// escape_with_allowed_html escapes the full value first, then restores only the
// supported tags. This preserves the legacy opt-in behavior without allowing
// arbitrary raw HTML through.
fn escape_with_allowed_html(value string) string {
mut escaped := escape_html(value)
for tag in allowed_html_tags {
escaped = escaped.replace(escape_html(tag), tag)
}
return escaped
}
// escape_html is a local deterministic HTML escape helper. It avoids depending
// on heavier generic replacement paths in the hot render loop.
fn escape_html(value string) string {
mut escaped := strings.new_builder(value.len)
for i := 0; i < value.len; i++ {
match value[i] {
`&` {
escaped.write_string('&amp;')
}
`<` {
escaped.write_string('&lt;')
}
`>` {
escaped.write_string('&gt;')
}
`"` {
escaped.write_string('&#34;')
}
`'` {
escaped.write_string('&#39;')
}
else {
escaped.write_u8(value[i])
}
}
}
escaped_value := escaped.str()
return escaped_value.clone()
}
// compress_html_output performs a small, predictable whitespace compression
// pass for HTML templates. It intentionally avoids regex work in the hot path.
fn compress_html_output(html string) string {
mut result := strings.new_builder(html.len)
mut pending_space := false
mut last_written := u8(0)
for i := 0; i < html.len; i++ {
c := html[i]
if c == `\n` || c == `\t` {
continue
}
if c == ` ` {
pending_space = true
continue
}
if pending_space {
if !(last_written == `>` && c == `<`) {
result.write_u8(` `)
last_written = ` `
}
pending_space = false
}
result.write_u8(c)
last_written = c
}
compressed := result.str()
return compressed.clone()
}
@@ -0,0 +1,429 @@
module dtm2
import os
import time
const test_root_name = 'dtm2_test'
fn dtm2_test_root() string {
return os.join_path(os.vtmp_dir(), '${test_root_name}_${os.getpid()}')
}
fn dtm2_outside_template_path() string {
return os.join_path(os.vtmp_dir(), '${test_root_name}_${os.getpid()}_outside.html')
}
fn testsuite_begin() {
root := dtm2_test_root()
os.rmdir_all(root) or {}
os.mkdir_all(os.join_path(root, 'partials'))!
outside_path := dtm2_outside_template_path()
os.rm(outside_path) or {}
os.write_file(outside_path, '<p>@title outside</p>')!
os.write_file(os.join_path(root, 'page.html'), '<main>@title @body @missing</main>')!
os.write_file(os.join_path(root, 'invalid.css'), 'body { color: red; }')!
os.write_file(os.join_path(root, 'raw.txt'), 'Raw: @body')!
os.write_file(os.join_path(root, 'with_include.html'),
'<header>@include "partials/nav"</header><main>@title</main>')!
os.write_file(os.join_path(root, 'partials', 'nav.html'), '<nav>@title</nav>')!
os.write_file(os.join_path(root, 'include_outside_relative.html'),
'@include "../${os.base(outside_path)}"')!
os.write_file(os.join_path(root, 'include_outside_absolute.html'), '@include "${outside_path}"')!
os.write_file(os.join_path(root, 'reload.html'), '<p>@title</p>')!
os.write_file(os.join_path(root, 'pinned.html'), '<em>@title</em>')!
os.write_file(os.join_path(root, 'path_alias.html'), '<section>@title</section>')!
os.write_file(os.join_path(root, 'prefix.html'), '<p>@unknown</p>')!
os.write_file(os.join_path(root, 'recursive.html'), '@include "recursive"')!
os.write_file(os.join_path(root, 'compress.html'), '<div>\n <span>@title</span>\n</div>')!
os.write_file(os.join_path(root, 'empty.html'), '')!
os.write_file(os.join_path(root, 'feed.xml'), '<feed><title>@title</title></feed>')!
os.write_file(os.join_path(root, 'custom.page'), '<article>@title</article>')!
os.write_file(os.join_path(root, 'custom.note'), 'Note: @body')!
os.write_file(os.join_path(root, 'override.html'), '<div>\n @title\n</div>')!
os.write_file(os.join_path(root, 'custom.view'), '<section>@title</section>')!
os.write_file(os.join_path(root, 'custom.mail'), 'Subject: @title')!
os.write_file(os.join_path(root, 'auto.fragment'), '<aside>@title</aside>')!
os.write_file(os.join_path(root, 'auto.message'), 'Auto: @title')!
os.write_file(os.join_path(root, 'bad.path'), '<p>@title</p>')!
os.write_file(os.join_path(root, 'extensions.json'), '{"html":[".view"],"text":["mail"]}')!
os.write_file(os.join_path(root, default_extension_config_filename),
'{"html":[".fragment"],"text":[".message"]}')!
os.write_file(os.join_path(root, 'invalid_extensions.json'),
'{"html":["../bad"],"text":["bad/value",".message"]}')!
}
fn testsuite_end() {
os.rmdir_all(dtm2_test_root()) or {}
os.rm(dtm2_outside_template_path()) or {}
}
fn new_test_manager(compress_html bool) &Manager {
return initialize(
template_dir: dtm2_test_root()
compress_html: compress_html
)
}
fn test_render_placeholders_escape_and_preserve_missing() {
mut manager := new_test_manager(false)
placeholders := {
'title': 'Hello'
'body': '<script>alert(1)</script>'
}
rendered := manager.expand('page.html', placeholders: &placeholders)
assert rendered == '<main>Hello &lt;script&gt;alert(1)&lt;/script&gt; @missing</main>\n'
assert manager.compiled_template_count() == 1
}
fn test_include_and_compiled_template_registry() {
mut manager := new_test_manager(true)
placeholders := {
'title': 'Home'
}
rendered := manager.expand('with_include.html', placeholders: &placeholders)
assert rendered == '<header><nav>Home</nav></header><main>Home</main>'
assert manager.compiled_template_count() == 1
_ = manager.expand('with_include.html', placeholders: &placeholders)
assert manager.compiled_template_count() == 1
}
fn test_include_html_suffix_keeps_only_allowed_tags() {
mut manager := new_test_manager(false)
placeholders := {
'body_#includehtml': '<thead><tr><td>safe</td></tr></thead><script>bad</script>'
'title': 'HTML'
}
rendered := manager.expand('page.html', placeholders: &placeholders)
assert rendered.contains('<thead><tr><td>safe</td></tr></thead>')
assert rendered.contains('&lt;script&gt;bad&lt;/script&gt;')
}
fn test_text_mode_always_escapes_html() {
mut manager := new_test_manager(false)
placeholders := {
'body_#includehtml': '<span>text</span>'
}
rendered := manager.expand('raw.txt', placeholders: &placeholders)
assert rendered == 'Raw: &lt;span&gt;text&lt;/span&gt;\n'
}
fn test_reload_modified_template_without_render_cache() {
mut manager := new_test_manager(false)
placeholders := {
'title': 'One'
}
first := manager.expand('reload.html', placeholders: &placeholders)
assert first == '<p>One</p>\n'
os.write_file(os.join_path(dtm2_test_root(), 'reload.html'), '<strong>@title</strong>')!
second := manager.expand('reload.html', placeholders: &placeholders)
assert second == '<strong>One</strong>\n'
assert manager.compiled_template_count() == 1
}
fn test_reload_same_second_same_size_template_content_change() {
path := os.join_path(dtm2_test_root(), 'same_size.html')
fixed_time := i64(2_306_102_495)
os.write_file(path, '<p>@title A</p>')!
os.utime(path, fixed_time, fixed_time)!
mut manager := new_test_manager(false)
placeholders := {
'title': 'One'
}
first := manager.expand('same_size.html', placeholders: &placeholders)
assert first == '<p>One A</p>\n'
os.write_file(path, '<b>@title B</b>')!
os.utime(path, fixed_time, fixed_time)!
second := manager.expand('same_size.html', placeholders: &placeholders)
assert second == '<b>One B</b>\n'
assert manager.compiled_template_count() == 1
}
fn test_reload_same_second_same_size_include_content_change() {
parent_path := os.join_path(dtm2_test_root(), 'same_size_include_parent.html')
include_path := os.join_path(dtm2_test_root(), 'partials', 'same_size_include.html')
fixed_time := i64(2_306_102_496)
os.write_file(parent_path, '<main>@include "partials/same_size_include"</main>')!
os.write_file(include_path, '<p>@title A</p>')!
os.utime(include_path, fixed_time, fixed_time)!
mut manager := new_test_manager(false)
placeholders := {
'title': 'One'
}
first := manager.expand('same_size_include_parent.html', placeholders: &placeholders)
assert first == '<main><p>One A</p></main>\n'
os.write_file(include_path, '<b>@title B</b>')!
os.utime(include_path, fixed_time, fixed_time)!
second := manager.expand('same_size_include_parent.html', placeholders: &placeholders)
assert second == '<main><b>One B</b></main>\n'
assert manager.compiled_template_count() == 1
}
fn test_reload_modified_include_without_render_cache() {
mut manager := new_test_manager(true)
placeholders := {
'title': 'Home'
}
first := manager.expand('with_include.html', placeholders: &placeholders)
assert first == '<header><nav>Home</nav></header><main>Home</main>'
time.sleep(1100 * time.millisecond)
os.write_file(os.join_path(dtm2_test_root(), 'partials', 'nav.html'),
'<nav class="updated">@title</nav>')!
second := manager.expand('with_include.html', placeholders: &placeholders)
assert second == '<header><nav class="updated">Home</nav></header><main>Home</main>'
assert manager.compiled_template_count() == 1
}
fn test_reload_disabled_keeps_cached_template_tree() {
mut manager := initialize(
template_dir: dtm2_test_root()
compress_html: false
reload_modified_templates: false
)
placeholders := {
'title': 'Pinned'
}
first := manager.expand('pinned.html', placeholders: &placeholders)
assert first == '<em>Pinned</em>\n'
os.write_file(os.join_path(dtm2_test_root(), 'pinned.html'), '<strong>@title</strong>')!
second := manager.expand('pinned.html', placeholders: &placeholders)
assert second == '<em>Pinned</em>\n'
assert manager.compiled_template_count() == 1
}
fn test_relative_and_absolute_paths_share_compiled_template() {
mut manager := new_test_manager(false)
placeholders := {
'title': 'Alias'
}
relative := manager.expand('path_alias.html', placeholders: &placeholders)
absolute := manager.expand(os.join_path(dtm2_test_root(), 'path_alias.html'),
placeholders: &placeholders
)
assert relative == '<section>Alias</section>\n'
assert absolute == relative
assert manager.compiled_template_count() == 1
}
fn test_template_path_cannot_escape_template_dir() {
mut manager := new_test_manager(false)
placeholders := {
'title': 'Escape'
}
relative_escape := manager.expand('../${os.base(dtm2_outside_template_path())}',
placeholders: &placeholders
)
absolute_escape := manager.expand(dtm2_outside_template_path(), placeholders: &placeholders)
assert relative_escape == internal_server_error
assert absolute_escape == internal_server_error
assert manager.compiled_template_count() == 0
}
fn test_cached_template_path_revalidates_symlink_escape() {
$if windows {
return
}
path := os.join_path(dtm2_test_root(), 'symlink_swap.html')
os.write_file(path, '<p>@title safe</p>')!
mut manager := new_test_manager(false)
placeholders := {
'title': 'Link'
}
first := manager.expand('symlink_swap.html', placeholders: &placeholders)
assert first == '<p>Link safe</p>\n'
os.rm(path)!
os.symlink(dtm2_outside_template_path(), path) or { return }
escaped := manager.expand('symlink_swap.html', placeholders: &placeholders)
assert escaped == internal_server_error
}
fn test_cached_include_dependency_revalidates_symlink_escape() {
$if windows {
return
}
parent_path := os.join_path(dtm2_test_root(), 'include_symlink_swap_parent.html')
include_path := os.join_path(dtm2_test_root(), 'partials', 'symlink_swap.html')
os.write_file(parent_path, '<main>@include "partials/symlink_swap"</main>')!
os.write_file(include_path, '<p>@title safe</p>')!
mut manager := new_test_manager(false)
placeholders := {
'title': 'Link'
}
first := manager.expand('include_symlink_swap_parent.html', placeholders: &placeholders)
assert first == '<main><p>Link safe</p></main>\n'
os.rm(include_path)!
os.symlink(dtm2_outside_template_path(), include_path) or { return }
escaped := manager.expand('include_symlink_swap_parent.html', placeholders: &placeholders)
assert escaped == internal_server_error
}
fn test_reload_disabled_does_not_reopen_cached_symlink_swap() {
$if windows {
return
}
path := os.join_path(dtm2_test_root(), 'pinned_symlink_swap.html')
os.write_file(path, '<p>@title safe</p>')!
mut manager := initialize(
template_dir: dtm2_test_root()
compress_html: false
reload_modified_templates: false
)
placeholders := {
'title': 'Pinned'
}
first := manager.expand('pinned_symlink_swap.html', placeholders: &placeholders)
assert first == '<p>Pinned safe</p>\n'
os.rm(path)!
os.symlink(dtm2_outside_template_path(), path) or { return }
second := manager.expand('pinned_symlink_swap.html', placeholders: &placeholders)
assert second == first
assert manager.compiled_template_count() == 1
}
fn test_include_path_cannot_escape_template_dir() {
mut manager := new_test_manager(false)
placeholders := {
'title': 'Escape'
}
relative_include := manager.expand('include_outside_relative.html', placeholders: &placeholders)
absolute_include := manager.expand('include_outside_absolute.html', placeholders: &placeholders)
assert relative_include == internal_server_error
assert absolute_include == internal_server_error
assert manager.compiled_template_count() == 0
}
fn test_custom_missing_placeholder_prefix() {
mut manager := new_test_manager(false)
placeholders := map[string]string{}
rendered := manager.expand('prefix.html',
placeholders: &placeholders
missing_placeholder_prefix: '?'
)
assert rendered == '<p>?unknown</p>\n'
}
fn test_recursive_include_returns_internal_server_error() {
mut manager := new_test_manager(false)
placeholders := map[string]string{}
rendered := manager.expand('recursive.html', placeholders: &placeholders)
assert rendered == internal_server_error
assert manager.compiled_template_count() == 0
}
fn test_html_compression_is_deterministic() {
mut manager := new_test_manager(true)
placeholders := {
'title': 'Compact'
}
rendered := manager.expand('compress.html', placeholders: &placeholders)
assert rendered == '<div><span>Compact</span></div>'
}
fn test_empty_template_renders_empty() {
mut manager := new_test_manager(false)
placeholders := map[string]string{}
rendered := manager.expand('empty.html', placeholders: &placeholders)
assert rendered == ''
}
fn test_xml_template_uses_html_rendering_mode() {
mut manager := new_test_manager(false)
placeholders := {
'title': '<escaped>'
}
rendered := manager.expand('feed.xml', placeholders: &placeholders)
assert rendered == '<feed><title>&lt;escaped&gt;</title></feed>\n'
}
fn test_custom_template_extension_map() {
mut manager := initialize(
template_dir: dtm2_test_root()
template_extensions: {
'.page': TemplateType.html
'note': TemplateType.text
}
)
html_placeholders := {
'title_#includehtml': '<span>custom</span><script>blocked</script>'
}
text_placeholders := {
'body_#includehtml': '<span>custom</span>'
}
html := manager.expand('custom.page', placeholders: &html_placeholders)
text := manager.expand('custom.note', placeholders: &text_placeholders)
assert html.contains('<span>custom</span>&lt;script&gt;blocked&lt;/script&gt;')
assert text == 'Note: &lt;span&gt;custom&lt;/span&gt;\n'
}
fn test_custom_template_extension_map_can_override_builtin_mapping() {
mut manager := initialize(
template_dir: dtm2_test_root()
compress_html: true
template_extensions: {
'.html': TemplateType.text
}
)
placeholders := {
'title': 'Text'
}
rendered := manager.expand('override.html', placeholders: &placeholders)
assert rendered == '<div>\n Text\n</div>\n'
}
fn test_json_extension_config_file() {
mut manager := initialize(
template_dir: dtm2_test_root()
extension_config_file: os.join_path(dtm2_test_root(), 'extensions.json')
)
placeholders := {
'title': '<Config>'
}
html := manager.expand('custom.view', placeholders: &placeholders)
text := manager.expand('custom.mail', placeholders: &placeholders)
assert html == '<section>&lt;Config&gt;</section>'
assert text == 'Subject: &lt;Config&gt;\n'
}
fn test_default_json_extension_config_file_is_loaded_from_template_dir() {
mut manager := initialize(
template_dir: dtm2_test_root()
compress_html: false
)
placeholders := {
'title': '<Auto>'
}
html := manager.expand('auto.fragment', placeholders: &placeholders)
text := manager.expand('auto.message', placeholders: &placeholders)
assert html == '<aside>&lt;Auto&gt;</aside>\n'
assert text == 'Auto: &lt;Auto&gt;\n'
}
fn test_invalid_json_extension_entries_are_ignored() {
mut manager := initialize(
template_dir: dtm2_test_root()
extension_config_file: os.join_path(dtm2_test_root(), 'invalid_extensions.json')
compress_html: false
)
placeholders := {
'title': '<Safe>'
}
ignored := manager.expand('bad.path', placeholders: &placeholders)
loaded := manager.expand('auto.message', placeholders: &placeholders)
assert ignored == internal_server_error
assert loaded == 'Auto: &lt;Safe&gt;\n'
}
fn test_missing_template_returns_internal_server_error() {
mut manager := new_test_manager(false)
placeholders := map[string]string{}
rendered := manager.expand('missing.html', placeholders: &placeholders)
assert rendered == internal_server_error
assert manager.compiled_template_count() == 0
}
fn test_invalid_template_extension_returns_internal_server_error() {
mut manager := new_test_manager(false)
placeholders := map[string]string{}
rendered := manager.expand('invalid.css', placeholders: &placeholders)
assert rendered == internal_server_error
assert manager.compiled_template_count() == 0
}