on master help sign in
Backslash escapes in text

\@ and \-- let a text line start with @ or --, \$ shows a $ that isn't a variable, \{ a brace that isn't an inline element, and \\ a backslash.

commit86bef1161201445c436f9392ef9a8566afa8c19b authoriko <mail@iko.soy> dateSat 26 Sep 2026, 03:45 (2 weeks ago) parent4361500
3 files changed+68−21
DESIGN.md+10 crates/htmlang-core/src/parser.rs+47−21 tests/regressions.rs+11
DESIGN.md+10
@@ -53,6 +53,16 @@ attributes is its content, and `{...}` puts elements inside a line of text:
5353@section [padding 8] Text after the attributes is content too.
5454```
5555
56+A backslash makes the next character literal: `\@` and `\--` let a text line
57+start with `@` or `--`, `\$` shows a `$` that isn't a variable, `\{` a brace
58+that isn't an inline element, and `\\` a backslash.
59+
60+```
61+\@htmlang on social media
62+@let price 5
63+@text \$price is $price dollars
64+```
65+
5666### Comments
5767
5868`--` at the start of a line begins a comment. Comments must be on their own
crates/htmlang-core/src/parser.rs+47−21
@@ -798,8 +798,12 @@ impl Evaluator {
798798 });
799799 }
800800
801− let var_warnings =
802− check_undefined_vars(&content, &ctx.variables, line_num, current_indent);
801+ let var_warnings = check_undefined_vars(
802+ &protect_escapes(&content),
803+ &ctx.variables,
804+ line_num,
805+ current_indent,
806+ );
803807 ctx.diagnostics.extend(var_warnings);
804808 track_var_refs(&content, &mut ctx.used_variables);
805809 let segments = parse_text_segments(&content, ctx);
@@ -1356,18 +1360,18 @@ fn parse_single_element(
13561360 let argument = if rest.is_empty() {
13571361 None
13581362 } else if kind == ElementKind::Link {
1359− let rest_sub = substitute_vars(&rest, &ctx.variables);
1363+ let rest_sub = substitute_vars(&protect_escapes(&rest), &ctx.variables);
13601364 if let Some((url, text)) = rest_sub.split_once(' ') {
13611365 let text = text.trim();
13621366 if !text.is_empty() {
13631367 children.push(Node::Text(parse_text_segments(text, ctx)));
13641368 }
1365− Some(url.to_string())
1369+ Some(restore_escapes(url))
13661370 } else {
1367− Some(rest_sub)
1371+ Some(restore_escapes(&rest_sub))
13681372 }
13691373 } else {
1370− Some(substitute_vars(&rest, &ctx.variables))
1374+ Some(restore_escapes(&substitute_vars(&protect_escapes(&rest), &ctx.variables)))
13711375 };
13721376
13731377 // For @slot, the argument is the slot name
@@ -2433,20 +2437,50 @@ fn split_chain(content: &str) -> Vec<String> {
24332437// Text segment parsing (inline {...} elements)
24342438// ---------------------------------------------------------------------------
24352439
2440+/// Escapes in text: `\@`, `\$`, `\{`, `\--` and `\\` stand for the
2441+/// character(s) themselves. While a text is processed, each is a private-use
2442+/// placeholder, so it can't start a variable, an inline element or a line.
2443+const ESCAPES: &[(&str, char, &str)] = &[
2444+ ("\\\\", '\u{E000}', "\\"),
2445+ ("\\$", '\u{E001}', "$"),
2446+ ("\\{", '\u{E002}', "{"),
2447+ ("\\@", '\u{E003}', "@"),
2448+ ("\\--", '\u{E004}', "--"),
2449+];
2450+
2451+fn protect_escapes(text: &str) -> String {
2452+ if !text.contains('\\') {
2453+ return text.to_string();
2454+ }
2455+ let mut out = text.to_string();
2456+ for (escape, placeholder, _) in ESCAPES {
2457+ out = out.replace(escape, &placeholder.to_string());
2458+ }
2459+ out
2460+}
2461+
2462+fn restore_escapes(text: &str) -> String {
2463+ let mut out = text.to_string();
2464+ for (_, placeholder, literal) in ESCAPES {
2465+ out = out.replace(*placeholder, literal);
2466+ }
2467+ out
2468+}
2469+
24362470fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec<TextSegment> {
24372471 let mut segments = Vec::new();
24382472 let mut current_text = String::new();
2439− let chars: Vec<char> = input.chars().collect();
2473+ let chars: Vec<char> = protect_escapes(input).chars().collect();
24402474 let mut i = 0;
24412475
24422476 while i < chars.len() {
24432477 if chars[i] == '{' && i + 1 < chars.len() && chars[i + 1] == '@' {
24442478 // Flush accumulated plain text
24452479 if !current_text.is_empty() {
2446− segments.push(TextSegment::Plain(substitute_vars(
2480+ segments.push(TextSegment::Plain(restore_escapes(&substitute_vars(
24472481 &current_text,
24482482 &ctx.variables,
2449− )));
2483+ ))));
24502484 current_text.clear();
24512485 }
24522486
@@ -2498,25 +2532,15 @@ fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec<TextSegment>
24982532 }
24992533
25002534 if !current_text.is_empty() {
2501− segments.push(TextSegment::Plain(substitute_vars(
2535+ segments.push(TextSegment::Plain(restore_escapes(&substitute_vars(
25022536 &current_text,
25032537 &ctx.variables,
2504− )));
2538+ ))));
25052539 }
25062540
25072541 segments
25082542}
25092543
2510−// ---------------------------------------------------------------------------
2511−// Variable substitution
2512−// ---------------------------------------------------------------------------
2513−
2514−// ---------------------------------------------------------------------------
2515−// Condition evaluation for @if
2516−// ---------------------------------------------------------------------------
2517−
2518−/// Inclusive integer range from `start` to `end` (counting down when
2519−/// `start > end`), stepping by `step`. Stops instead of overflowing.
25202544/// The items of a text list: a range `A..B [step N]`, or comma-separated.
25212545fn text_list_items(list: &str) -> Vec<String> {
25222546 if let Some((start, rest)) = list.split_once("..") {
@@ -2552,6 +2576,8 @@ fn bind_item(vars: &mut HashMap<String, String>, source: &str, target: &str) {
25522576 vars.entry(target.to_string()).or_default();
25532577}
25542578
2579+/// Inclusive integer range from `start` to `end` (counting down when
2580+/// `start > end`), stepping by `step`. Stops instead of overflowing.
25552581fn numeric_range(start: i64, end: i64, step: i64) -> Vec<String> {
25562582 let mut items = Vec::new();
25572583 let mut n = start;
tests/regressions.rs+11
@@ -440,3 +440,14 @@ fn style_aliases_point_to_css() {
440440 assert!(out.contains("border:1px solid red"), "{}", out);
441441 assert!(out.contains("border-radius:4px 4px 0 0"), "{}", out);
442442}
443+
444+#[test]
445+fn backslash_escapes_in_text() {
446+ let out = compile(
447+ "@let price 9\n\\@user says\n\\-- not a comment\n@text \\$price is $price, \\{@b} \\\\\n@paragraph\n Now {@text \\$5} only\n",
448+ );
449+ assert!(out.contains("@user says"), "{}", out);
450+ assert!(out.contains("-- not a comment"), "{}", out);
451+ assert!(out.contains("$price is 9, {@b} \\<"), "{}", out);
452+ assert!(out.contains(">$5</span> only"), "{}", out);
453+}