Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions vlib/x/templating/dtm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
A simple template manager integrated into the V project, designed to combine the power of V
templates with Veb, without the need to recompile the application with every change.

> **Status:** `x.templating.dtm` is the legacy version of Dynamic Template
> Manager. New projects should use `x.templating.dtm2`, which provides the
> modern runtime renderer and parsed-template cache.
>
> Existing `dtm` users can keep their current API while migrating progressively:
> the v1 facade now delegates rendering to the DTM2 engine internally, preserving
> source compatibility while removing the old rendered-cache server from the
> main rendering path.

## Quick Start

Using the dynamic template manager (named `dtm` in this readme) is relatively straightforward.
Expand Down
926 changes: 485 additions & 441 deletions vlib/x/templating/dtm/dynamic_template_manager.v

Large diffs are not rendered by default.

333 changes: 333 additions & 0 deletions vlib/x/templating/dtm/dynamic_template_manager_behavior_test.v
Original file line number Diff line number Diff line change
@@ -0,0 +1,333 @@
// vtest retry: 3
module dtm

import os
import time

const behavior_temp_dtm_dir = 'dynamic_template_manager_behavior_test'
const behavior_temp_cache_dir = 'vcache_dtm'
const behavior_temp_templates_dir = 'templates'
const behavior_vtmp_dir = os.vtmp_dir()

fn behavior_test_root_dir() string {
return os.join_path(behavior_vtmp_dir, '${behavior_temp_dtm_dir}_${os.getpid()}')
}

fn testsuite_begin() {
temp_folder := behavior_test_root_dir()
os.rmdir_all(temp_folder) or {}
os.mkdir_all(os.join_path(temp_folder, behavior_temp_cache_dir))!
os.mkdir_all(os.join_path(temp_folder, behavior_temp_templates_dir))!

templates_path := os.join_path(temp_folder, behavior_temp_templates_dir)
os.write_file(os.join_path(templates_path, 'page.html'), [
'<!doctype html>',
'<title>@title</title>',
'<p>@body</p>',
'<p>@count</p>',
].join('\n'))!
os.write_file(os.join_path(templates_path, 'page.txt'), [
'Title: @title',
'Body: @body',
].join('\n'))!
os.write_file(os.join_path(templates_path, 'layout.html'), [
'<header>Start</header>',
"@include 'partial'",
'<footer>End</footer>',
].join('\n'))!
os.write_file(os.join_path(templates_path, 'partial.html'), '<section>@title</section>')!
os.write_file(os.join_path(templates_path, 'cache.html'), '<main>@title</main>')!
os.write_file(os.join_path(templates_path, 'cache_file_update.html'), '<main>@title</main>')!
os.write_file(os.join_path(templates_path, 'no_store.html'), '<main>@title</main>')!
os.write_file(os.join_path(templates_path, 'forever.html'), '<main>@title</main>')!
os.write_file(os.join_path(templates_path, 'disk_cache.html'), '<main>@title</main>')!
os.write_file(os.join_path(templates_path, 'include_html.html'), '<article>@content</article>')!
os.write_file(os.join_path(templates_path, 'include_text.txt'), 'Text: @content')!
os.write_file(os.join_path(templates_path, 'invalid.css'), 'body { color: red; }')!
}

fn testsuite_end() {
os.rmdir_all(behavior_test_root_dir()) or {}
}

fn new_behavior_dtm(active_cache_server bool, compress_html bool, max_size_data_in_mem int) &DynamicTemplateManager {
temp_folder := behavior_test_root_dir()
return initialize(
active_cache_server: active_cache_server
compress_html: compress_html
max_size_data_in_mem: max_size_data_in_mem
test_cache_dir: os.join_path(temp_folder, behavior_temp_cache_dir)
test_template_dir: os.join_path(temp_folder, behavior_temp_templates_dir)
)
}

fn behavior_template_path(file_name string) string {
return os.join_path(behavior_test_root_dir(), behavior_temp_templates_dir, file_name)
}

fn rendered_cache_count(dtmi &DynamicTemplateManager) int {
rlock dtmi.template_caches {
return dtmi.template_caches.len
}
}

fn test_expand_escapes_html_placeholders_by_default() {
mut dtmi := new_behavior_dtm(false, false, max_size_data_in_memory)
placeholders := {
'title': DtmMultiTypeMap('Hello <V>')
'body': DtmMultiTypeMap('<script>alert(1)</script>')
'count': DtmMultiTypeMap(7)
}

rendered := dtmi.expand(behavior_template_path('page.html'), placeholders: &placeholders)

assert rendered.contains('<title>Hello &lt;V&gt;</title>')
assert rendered.contains('<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>')
assert rendered.contains('<p>7</p>')
}

fn test_expand_does_not_double_escape_placeholder_values_containing_dollar() {
mut dtmi := new_behavior_dtm(false, false, max_size_data_in_memory)
placeholders := {
'title': DtmMultiTypeMap('Cost $5 <V>')
'body': DtmMultiTypeMap('Body')
'count': DtmMultiTypeMap(1)
}

rendered := dtmi.expand(behavior_template_path('page.html'), placeholders: &placeholders)

assert rendered.contains('<title>Cost $5 &lt;V&gt;</title>')
}

fn test_expand_escapes_html_entities_and_quotes() {
mut dtmi := new_behavior_dtm(false, false, max_size_data_in_memory)
placeholders := {
'title': DtmMultiTypeMap('A & B "quoted" \'single\'')
'body': DtmMultiTypeMap('Body')
'count': DtmMultiTypeMap(1)
}

rendered := dtmi.expand(behavior_template_path('page.html'), placeholders: &placeholders)

assert rendered.contains('<title>A &amp; B &#34;quoted&#34; &#39;single&#39;</title>')
}

fn test_expand_escapes_text_templates() {
mut dtmi := new_behavior_dtm(false, false, max_size_data_in_memory)
placeholders := {
'title': DtmMultiTypeMap('Plain <Title>')
'body': DtmMultiTypeMap('<strong>raw text</strong>')
}

rendered := dtmi.expand(behavior_template_path('page.txt'), placeholders: &placeholders)

assert rendered.contains('Title: Plain &lt;Title&gt;')
assert rendered.contains('Body: &lt;strong&gt;raw text&lt;/strong&gt;')
}

fn test_expand_compresses_html_without_regex_failure() {
mut dtmi := new_behavior_dtm(false, true, max_size_data_in_memory)
placeholders := {
'title': DtmMultiTypeMap('Compressed')
'body': DtmMultiTypeMap('Body')
'count': DtmMultiTypeMap(1)
}

rendered := dtmi.expand(behavior_template_path('page.html'), placeholders: &placeholders)

assert rendered != internat_server_error
assert !rendered.contains('\n')
assert rendered.contains('<!doctype html><title>Compressed</title><p>Body</p><p>1</p>')
}

fn test_expand_with_empty_placeholders_keeps_template_renderable() {
mut dtmi := new_behavior_dtm(false, false, max_size_data_in_memory)
placeholders := map[string]DtmMultiTypeMap{}

rendered := dtmi.expand(behavior_template_path('page.html'), placeholders: &placeholders)

assert rendered.contains('<title>$title</title>')
assert rendered.contains('<p>$body</p>')
assert rendered.contains('<p>$count</p>')
}

fn test_expand_resolves_relative_includes() {
mut dtmi := new_behavior_dtm(false, false, max_size_data_in_memory)
placeholders := {
'title': DtmMultiTypeMap('Included <Title>')
}

rendered := dtmi.expand(behavior_template_path('layout.html'), placeholders: &placeholders)

assert rendered.contains('<header>Start</header>')
assert rendered.contains('<section>Included &lt;Title&gt;</section>')
assert rendered.contains('<footer>End</footer>')
}

fn test_includehtml_preserves_allowed_tags_and_escapes_unlisted_tags() {
mut dtmi := new_behavior_dtm(false, false, max_size_data_in_memory)
placeholders := {
'content_#includehtml': DtmMultiTypeMap('<span>allowed</span><script>blocked()</script>')
}

rendered :=
dtmi.expand(behavior_template_path('include_html.html'), placeholders: &placeholders)

assert rendered.contains('<article><span>allowed</span>&lt;script&gt;blocked()&lt;/script&gt;</article>')
}

fn test_includehtml_is_still_escaped_in_text_templates() {
mut dtmi := new_behavior_dtm(false, false, max_size_data_in_memory)
placeholders := {
'content_#includehtml': DtmMultiTypeMap('<span>text</span>')
}

rendered := dtmi.expand(behavior_template_path('include_text.txt'), placeholders: &placeholders)

assert rendered.contains('Text: &lt;span&gt;text&lt;/span&gt;')
}

fn test_expand_returns_internal_error_for_missing_template() {
mut dtmi := new_behavior_dtm(false, false, max_size_data_in_memory)
placeholders := map[string]DtmMultiTypeMap{}

rendered := dtmi.expand(behavior_template_path('missing.html'), placeholders: &placeholders)

assert rendered == internat_server_error
}

fn test_expand_returns_internal_error_for_invalid_template_extension() {
mut dtmi := new_behavior_dtm(false, false, max_size_data_in_memory)
placeholders := map[string]DtmMultiTypeMap{}

rendered := dtmi.expand(behavior_template_path('invalid.css'), placeholders: &placeholders)

assert rendered == internat_server_error
}

fn test_expand_without_cache_renders_fresh_placeholder_content() {
mut dtmi := new_behavior_dtm(false, false, max_size_data_in_memory)
first_placeholders := {
'title': DtmMultiTypeMap('first')
}
second_placeholders := {
'title': DtmMultiTypeMap('second')
}

first := dtmi.expand(behavior_template_path('cache.html'), placeholders: &first_placeholders)
second := dtmi.expand(behavior_template_path('cache.html'), placeholders: &second_placeholders)

assert first.contains('<main>first</main>')
assert second.contains('<main>second</main>')
}

fn test_expand_with_cache_updates_when_placeholder_content_changes() {
mut dtmi := new_behavior_dtm(true, false, max_size_data_in_memory)
defer {
dtmi.disable_cache()
}
first_placeholders := {
'title': DtmMultiTypeMap('cached first')
}
second_placeholders := {
'title': DtmMultiTypeMap('cached second')
}

first := dtmi.expand(behavior_template_path('cache.html'), placeholders: &first_placeholders)
second := dtmi.expand(behavior_template_path('cache.html'), placeholders: &first_placeholders)
third := dtmi.expand(behavior_template_path('cache.html'), placeholders: &second_placeholders)

assert first.contains('<main>cached first</main>')
assert second == first
assert third.contains('<main>cached second</main>')
}

fn test_expand_with_legacy_cache_enabled_uses_new_parsed_template_engine() {
mut dtmi := new_behavior_dtm(true, false, max_size_data_in_memory)
defer {
dtmi.disable_cache()
}
placeholders := {
'title': DtmMultiTypeMap('stable cached value')
}

first := dtmi.expand(behavior_template_path('cache.html'), placeholders: &placeholders)
second := dtmi.expand(behavior_template_path('cache.html'), placeholders: &placeholders)

assert first == second
assert rendered_cache_count(dtmi) == 0
assert dtmi.render_engine.compiled_template_count() == 1
}

fn test_expand_with_cache_updates_when_template_file_changes() {
mut dtmi := new_behavior_dtm(true, false, max_size_data_in_memory)
defer {
dtmi.disable_cache()
}
template_path := behavior_template_path('cache_file_update.html')
placeholders := {
'title': DtmMultiTypeMap('file driven')
}

first := dtmi.expand(template_path, placeholders: &placeholders)
time.sleep(1100 * time.millisecond)
os.write_file(template_path, '<section>@title</section>')!
second := dtmi.expand(template_path, placeholders: &placeholders)

assert first.contains('<main>file driven</main>')
assert second.contains('<section>file driven</section>')
}

fn test_cache_delay_minus_one_skips_rendered_cache_storage() {
mut dtmi := new_behavior_dtm(true, false, max_size_data_in_memory)
defer {
dtmi.disable_cache()
}
placeholders := {
'title': DtmMultiTypeMap('no store')
}

rendered := dtmi.expand(behavior_template_path('no_store.html'),
placeholders: &placeholders
cache_delay_expiration: -1
)

assert rendered.contains('<main>no store</main>')
assert rendered_cache_count(dtmi) == 0
}

fn test_cache_delay_zero_is_accepted_without_rendered_cache_storage() {
mut dtmi := new_behavior_dtm(true, false, max_size_data_in_memory)
defer {
dtmi.disable_cache()
}
placeholders := {
'title': DtmMultiTypeMap('forever')
}

rendered := dtmi.expand(behavior_template_path('forever.html'),
placeholders: &placeholders
cache_delay_expiration: 0
)

assert rendered.contains('<main>forever</main>')
assert rendered_cache_count(dtmi) == 0
}

fn test_memory_limit_zero_does_not_create_rendered_disk_cache() {
mut dtmi := new_behavior_dtm(true, false, 0)
defer {
dtmi.disable_cache()
}
placeholders := {
'title': DtmMultiTypeMap('disk mode')
}

rendered := dtmi.expand(behavior_template_path('disk_cache.html'), placeholders: &placeholders)

assert rendered.contains('<main>disk mode</main>')
assert rendered_cache_count(dtmi) == 0
for file_name in os.ls(dtmi.template_cache_folder)! {
assert !file_name.ends_with('.cache')
}
}
Loading
Loading