Initial project import
This commit is contained in:
commit
23f2d29e32
11957 files changed
+1754577
No files matched your search
@@ -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('&')
|
||||
}
|
||||
`<` {
|
||||
escaped.write_string('<')
|
||||
}
|
||||
`>` {
|
||||
escaped.write_string('>')
|
||||
}
|
||||
`"` {
|
||||
escaped.write_string('"')
|
||||
}
|
||||
`'` {
|
||||
escaped.write_string(''')
|
||||
}
|
||||
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 <script>alert(1)</script> @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('<script>bad</script>')
|
||||
}
|
||||
|
||||
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: <span>text</span>\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><escaped></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><script>blocked</script>')
|
||||
assert text == 'Note: <span>custom</span>\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><Config></section>'
|
||||
assert text == 'Subject: <Config>\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><Auto></aside>\n'
|
||||
assert text == 'Auto: <Auto>\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: <Safe>\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
|
||||
}
|
||||
Reference in new issue
Block a user