From e1db36751a37539d4c5ba5bfd45859bf9c483c9f Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Wed, 11 Mar 2026 15:56:01 +0300 Subject: [PATCH] parser: V doesn't ignore variables in HTML comment template (fixes #12171) --- vlib/v/parser/tmpl.v | 281 ++++++++++++++++++------ vlib/v/tests/html_comment_template.html | 5 + vlib/v/tests/tmpl_test.v | 11 + 3 files changed, 225 insertions(+), 72 deletions(-) create mode 100644 vlib/v/tests/html_comment_template.html diff --git a/vlib/v/parser/tmpl.v b/vlib/v/parser/tmpl.v index e69503bfb..8749975e3 100644 --- a/vlib/v/parser/tmpl.v +++ b/vlib/v/parser/tmpl.v @@ -20,6 +20,17 @@ enum State { // span // span.{ } +struct HtmlCommentSegment { + text string + is_comment bool +} + +struct HtmlCommentLineInfo { + segments []HtmlCommentSegment + masked_line string + ends_in_comment bool +} + fn (mut state State) update(line string) { trimmed_line := line.trim_space() if is_html_open_tag('style', line) { @@ -35,6 +46,65 @@ fn (mut state State) update(line string) { const tmpl_str_end = "')\n" +fn parse_html_comment_line(line string, start_in_comment bool) HtmlCommentLineInfo { + mut segments := []HtmlCommentSegment{} + mut masked := strings.new_builder(line.len) + mut in_comment := start_in_comment + mut segment_start := 0 + mut i := 0 + for i < line.len { + if !in_comment && i + 3 < line.len && line[i..i + 4] == '' { + i += 3 + text := line[segment_start..i] + segments << HtmlCommentSegment{ + text: text + is_comment: true + } + masked.write_string(strings.repeat(` `, text.len)) + segment_start = i + in_comment = false + continue + } + i++ + } + if segment_start < line.len { + text := line[segment_start..] + segments << HtmlCommentSegment{ + text: text + is_comment: in_comment + } + if in_comment { + masked.write_string(strings.repeat(` `, text.len)) + } else { + masked.write_string(text) + } + } else if line.len == 0 { + segments << HtmlCommentSegment{ + text: '' + is_comment: in_comment + } + } + return HtmlCommentLineInfo{ + segments: segments + masked_line: masked.str() + ends_in_comment: in_comment + } +} + // check HTML open tag `` fn is_html_open_tag(name string, s string) bool { trimmed_line := s.trim_space() @@ -235,10 +305,26 @@ fn rewrite_complex_template_at_expressions(line string) string { return b.str() } -fn insert_template_code(fn_name string, tmpl_str_start string, line string) string { - // HTML, may include `@var` - // escaped by cgen, unless it's a `veb.RawHtml` string - trailing_bs := tmpl_str_end + 'sb_${fn_name}.write_u8(92)\n' + tmpl_str_start +fn escape_template_literal(line string) string { + mut sb := strings.new_builder(line.len + 8) + for i := 0; i < line.len; i++ { + ch := line[i] + match ch { + `\\` { + sb.write_string('\\\\') + } + `'` { + sb.write_string("\\'") + } + else { + sb.write_u8(ch) + } + } + } + return sb.str() +} + +fn rewrite_template_code(line string) string { rewritten_line := rewrite_complex_template_at_expressions(line) mut sb := strings.new_builder(rewritten_line.len + 16) mut i := 0 @@ -289,12 +375,36 @@ fn insert_template_code(fn_name string, tmpl_str_start string, line string) stri if comptime_call_str.contains("\\'") { rline = rline.replace(comptime_call_str, comptime_call_str.replace("\\'", r"'")) } + return rline +} + +fn finalize_template_code(fn_name string, tmpl_str_start string, line string) string { + // HTML, may include `@var` + // escaped by cgen, unless it's a `veb.RawHtml` string + trailing_bs := tmpl_str_end + 'sb_${fn_name}.write_u8(92)\n' + tmpl_str_start + mut rline := line if rline.ends_with('\\') { rline = rline[0..rline.len - 2] + trailing_bs } return rline } +fn insert_template_code(fn_name string, tmpl_str_start string, line string) string { + return finalize_template_code(fn_name, tmpl_str_start, rewrite_template_code(line)) +} + +fn insert_template_code_from_segments(fn_name string, tmpl_str_start string, segments []HtmlCommentSegment) string { + mut sb := strings.new_builder(64) + for segment in segments { + if segment.is_comment { + sb.write_string(escape_template_literal(segment.text)) + } else { + sb.write_string(rewrite_template_code(segment.text)) + } + } + return finalize_template_code(fn_name, tmpl_str_start, sb.str()) +} + fn normalize_keyword_template_interpolations(line string) string { mut sb := strings.new_builder(line.len) mut i := 0 @@ -319,6 +429,60 @@ fn normalize_keyword_template_interpolations(line string) string { return sb.str() } +fn translate_template_keys(line string) string { + mut line_ := line + mut search_start := 0 + for { + pos := line_.index_after('%', search_start) or { break } + is_raw := pos + 4 < line_.len && line_[pos..pos + 5] == '%raw ' + if is_raw { + // Start reading the key after "raw " (pos + 5) + mut end := pos + 5 + // valid variable characters + for end < line_.len && (line_[end].is_letter() || line_[end] == `_`) { + end++ + } + // Extract the key + key := line_[pos + 5..end] + if key.len > 0 { + // Replace '%raw key' with just '${key}' + line_ = line_.replace('%raw ${key}', '\${veb.raw(veb.tr(ctx.lang.str(), "${key}"))}') + } + search_start = pos + 1 + } else { + if pos + 1 < line_.len && line_[pos + 1].is_letter() { + mut end := pos + 1 + for end < line_.len && (line_[end].is_letter() || line_[end] == `_`) { + end++ + } + key := line_[pos + 1..end] + // println('GOT tr key line="${line_}" key="${key}"') + line_ = line_.replace('%${key}', '\${veb.tr(ctx.lang.str(), "${key}")}') + search_start = pos + 1 + } else { + // Not a valid translation key, skip this % + search_start = pos + 1 + } + } + } + return line_ +} + +fn translate_template_key_segments(segments []HtmlCommentSegment) []HtmlCommentSegment { + mut translated := []HtmlCommentSegment{cap: segments.len} + for segment in segments { + translated << HtmlCommentSegment{ + text: if segment.is_comment { + segment.text + } else { + translate_template_keys(segment.text) + } + is_comment: segment.is_comment + } + } + return translated +} + // struct to track dependecies and cache templates for reuse without io struct DependencyCache { pub mut: @@ -482,22 +646,37 @@ fn veb_tmpl_${fn_name}() string { } mut in_span := false + mut in_html_comment := false mut end_of_line_pos := 0 mut start_of_line_pos := 0 mut tline_number := -1 // keep the original line numbers, even after insert/delete ops on lines; `i` changes for i := 0; i < lines.len; i++ { line := lines[i] + mut comment_info := HtmlCommentLineInfo{ + segments: [ + HtmlCommentSegment{ + text: line + is_comment: false + }, + ] + masked_line: line + ends_in_comment: false + } tline_number++ start_of_line_pos = end_of_line_pos end_of_line_pos += line.len + 1 + if state == .html || in_html_comment { + comment_info = parse_html_comment_line(line, in_html_comment) + in_html_comment = comment_info.ends_in_comment + } if state != .simple { - state.update(line) + state.update(comment_info.masked_line) } $if trace_tmpl ? { eprintln('>>> tfile: ${template_file}, spos: ${start_of_line_pos:6}, epos:${end_of_line_pos:6}, fi: ${tline_number:5}, i: ${i:5}, state: ${state:10}, line: ${line}') } - if line.contains('@header') { - position := line.index('@header') or { 0 } + if comment_info.masked_line.contains('@header') { + position := comment_info.masked_line.index('@header') or { 0 } p.error_with_error(errors.Error{ message: "Please use @include 'header' instead of @header (deprecated)" file_path: template_file @@ -511,8 +690,8 @@ fn veb_tmpl_${fn_name}() string { }) continue } - if line.contains('@footer') { - position := line.index('@footer') or { 0 } + if comment_info.masked_line.contains('@footer') { + position := comment_info.masked_line.index('@footer') or { 0 } p.error_with_error(errors.Error{ message: "Please use @include 'footer' instead of @footer (deprecated)" file_path: template_file @@ -526,7 +705,7 @@ fn veb_tmpl_${fn_name}() string { }) continue } - if line.contains('@include ') { + if comment_info.masked_line.contains('@include ') { lines.delete(i) resolved := p.process_includes(template_file, tline_number, line, mut &dc) or { if err is IncludeError { @@ -566,7 +745,7 @@ fn veb_tmpl_${fn_name}() string { i-- continue } - if line.contains('@if ') { + if comment_info.masked_line.contains('@if ') { source.writeln(tmpl_str_end) // tmpl_str_end contains '\n', so writeln creates 2 lines: ')' and empty p.template_line_map << ast.TemplateLineInfo{ @@ -577,7 +756,7 @@ fn veb_tmpl_${fn_name}() string { tmpl_path: template_file tmpl_line: tline_number } - pos := line.index('@if') or { continue } + pos := comment_info.masked_line.index('@if') or { continue } source.writeln('if ' + line[pos + 4..] + '{') p.template_line_map << ast.TemplateLineInfo{ tmpl_path: template_file @@ -586,7 +765,7 @@ fn veb_tmpl_${fn_name}() string { source.write_string(tmpl_str_start) continue } - if line.contains('@end') { + if comment_info.masked_line.contains('@end') { source.writeln(tmpl_str_end) // tmpl_str_end contains '\n', so writeln creates 2 lines: ')' and empty p.template_line_map << ast.TemplateLineInfo{ @@ -605,7 +784,7 @@ fn veb_tmpl_${fn_name}() string { source.write_string(tmpl_str_start) continue } - if line.contains('@else') { + if comment_info.masked_line.contains('@else') { source.writeln(tmpl_str_end) // tmpl_str_end contains '\n', so writeln creates 2 lines: ')' and empty p.template_line_map << ast.TemplateLineInfo{ @@ -616,7 +795,7 @@ fn veb_tmpl_${fn_name}() string { tmpl_path: template_file tmpl_line: tline_number } - pos := line.index('@else') or { continue } + pos := comment_info.masked_line.index('@else') or { continue } source.writeln('}' + line[pos + 1..] + '{') p.template_line_map << ast.TemplateLineInfo{ tmpl_path: template_file @@ -626,7 +805,7 @@ fn veb_tmpl_${fn_name}() string { source.write_string(tmpl_str_start) continue } - if line.contains('@for') { + if comment_info.masked_line.contains('@for') { source.writeln(tmpl_str_end) // tmpl_str_end contains '\n', so writeln creates 2 lines: ')' and empty p.template_line_map << ast.TemplateLineInfo{ @@ -637,7 +816,7 @@ fn veb_tmpl_${fn_name}() string { tmpl_path: template_file tmpl_line: tline_number } - pos := line.index('@for') or { continue } + pos := comment_info.masked_line.index('@for') or { continue } source.writeln('for ' + line[pos + 4..] + '{') p.template_line_map << ast.TemplateLineInfo{ tmpl_path: template_file @@ -648,7 +827,8 @@ fn veb_tmpl_${fn_name}() string { } if state == .simple { // by default, just copy 1:1 - source.writeln(insert_template_code(fn_name, tmpl_str_start, line)) + source.writeln(insert_template_code_from_segments(fn_name, tmpl_str_start, + comment_info.segments)) p.template_line_map << ast.TemplateLineInfo{ tmpl_path: template_file tmpl_line: tline_number @@ -659,8 +839,8 @@ fn veb_tmpl_${fn_name}() string { // The .simple mode ends here. The rest handles .html/.css/.js state transitions. if state != .simple { - if line.contains('@js ') { - pos := line.index('@js') or { continue } + if comment_info.masked_line.contains('@js ') { + pos := comment_info.masked_line.index('@js') or { continue } source.write_string('') @@ -670,8 +850,8 @@ fn veb_tmpl_${fn_name}() string { } continue } - if line.contains('@css ') { - pos := line.index('@css') or { continue } + if comment_info.masked_line.contains('@css ') { + pos := comment_info.masked_line.index('@css') or { continue } source.write_string('') @@ -685,7 +865,7 @@ fn veb_tmpl_${fn_name}() string { match state { .html { - line_t := line.trim_space() + line_t := comment_info.masked_line.trim_space() if line_t.starts_with('span.') && line.ends_with('{') { // `span.header {` => `` class := line.find_between('span.', '{').trim_space() @@ -733,7 +913,8 @@ fn veb_tmpl_${fn_name}() string { } .js { // if line.contains('//V_TEMPLATE') { - source.writeln(insert_template_code(fn_name, tmpl_str_start, line)) + source.writeln(insert_template_code_from_segments(fn_name, tmpl_str_start, + comment_info.segments)) p.template_line_map << ast.TemplateLineInfo{ tmpl_path: template_file tmpl_line: tline_number @@ -760,54 +941,10 @@ fn veb_tmpl_${fn_name}() string { // %translation_key => ${tr('translation_key')} // Process all %key patterns on this line - mut line_ := line - mut search_start := 0 - for { - pos := line_.index_after('%', search_start) or { break } - is_raw := pos + 4 < line_.len && line_[pos..pos + 5] == '%raw ' - if is_raw { - // Start reading the key after "raw " (pos + 5) - mut end := pos + 5 - // valid variable characters - for end < line_.len && (line_[end].is_letter() || line_[end] == `_`) { - end++ - } - // Extract the key - key := line_[pos + 5..end] - if key.len > 0 { - // Replace '%raw key' with just '${key}' - line_ = line_.replace('%raw ${key}', '\${veb.raw(veb.tr(ctx.lang.str(), "${key}"))}') - } - search_start = pos + 1 - } else { - if pos + 1 < line_.len && line_[pos + 1].is_letter() { - mut end := pos + 1 - for end < line_.len && (line_[end].is_letter() || line_[end] == `_`) { - end++ - } - key := line_[pos + 1..end] - // println('GOT tr key line="${line_}" key="${key}"') - line_ = line_.replace('%${key}', '\${veb.tr(ctx.lang.str(), "${key}")}') - search_start = pos + 1 - } else { - // Not a valid translation key, skip this % - search_start = pos + 1 - } - } - } - if line_ != line { - source.writeln(insert_template_code(fn_name, tmpl_str_start, line_)) - p.template_line_map << ast.TemplateLineInfo{ - tmpl_path: template_file - tmpl_line: tline_number - } - } else { - // by default, just copy 1:1 - source.writeln(insert_template_code(fn_name, tmpl_str_start, line)) - p.template_line_map << ast.TemplateLineInfo{ - tmpl_path: template_file - tmpl_line: tline_number - } + source.writeln(insert_template_code_from_segments(fn_name, tmpl_str_start, translate_template_key_segments(comment_info.segments))) + p.template_line_map << ast.TemplateLineInfo{ + tmpl_path: template_file + tmpl_line: tline_number } } diff --git a/vlib/v/tests/html_comment_template.html b/vlib/v/tests/html_comment_template.html new file mode 100644 index 000000000..b0e973802 --- /dev/null +++ b/vlib/v/tests/html_comment_template.html @@ -0,0 +1,5 @@ + + +

hello

diff --git a/vlib/v/tests/tmpl_test.v b/vlib/v/tests/tmpl_test.v index 1d5d7cf3c..39a3e3600 100644 --- a/vlib/v/tests/tmpl_test.v +++ b/vlib/v/tests/tmpl_test.v @@ -95,6 +95,17 @@ fn test_tmpl_interpolation() { assert s == 'result: foo\n' } +fn html_comment_tmpl() string { + return $tmpl('html_comment_template.html') +} + +fn test_tmpl_html_comments_do_not_interpolate() { + result := html_comment_tmpl() + assert result.contains('') + assert result.contains('') + assert result.contains('

hello

') +} + fn map_index_tmpl() string { lang := { 'test_entry': 'Test Text' -- 2.39.5