on master help sign in
Verbatim text bodies, one way

The bodies of @style, @head and @raw are kept exactly as written, like those of @script and @markdown. This fixes CSS custom properties (--brand: red) being dropped as comments. @raw takes an indented body or the rest of its line instead of """ quotes, and scoped @style in a function nests under the scope class, so multi-line rules work. htmlang upgrade rewrites @raw """...""".

commit75da6a728abae81c180fa687a1012ff7e07e6355 authoriko <mail@iko.soy> dateSat 26 Sep 2026, 03:06 (2 weeks ago) parentffdc0cc
12 files changed+154−109
DESIGN.md+9−4 crates/htmlang-core/src/parser.rs+29−14 crates/htmlang-core/src/syntax.rs+41−69 editors/vscode/README.md+1−1 examples/demo.hl+6−8 src/bin/htmlang_lsp/docs.rs+1−1 src/fmt.rs+2−2 src/upgrade.rs+40−3 tests/regressions.rs+18 tests/snapshots.rs+1−1 tests/snapshots/new_directives.html+5−5 tests/snapshots/raw_html.hl+1−1
DESIGN.md+9−4
@@ -76,12 +76,16 @@ gets the indented children.
7676
7777### Raw content
7878
79−`@raw """ ... """` is pasted into the output verbatim.
79+`@raw` pastes HTML into the output verbatim: the rest of its line, or its
80+indented block. The bodies of `@style`, `@head`, `@script` and `@markdown` are
81+verbatim too (a `--` line in them is not a comment).
8082
8183```
82−@raw """
83−<div class="custom-widget"></div>
84−"""
84+@raw <hr class="fancy">
85+@raw
86+ <div class="custom-widget">
87+ <span>Hand-written HTML</span>
88+ </div>
8589```
8690
8791## Elements
@@ -484,6 +488,7 @@ removed form with its replacement.
484488| `$a ~ " " ~ $b` | `"$a $b"` |
485489| `@each $x in LIST [page N]` | Split the list, or filter it with `@if` |
486490| `...$bundle` | `$bundle` |
491+| `@raw """ ... """` | `@raw` with an indented body |
487492| `@keyframes NAME` with `from [opacity 0]` | The `@keyframes` rule in `@style` |
488493| `@include lib.hl as ui` | `@include lib.hl`, without the `ui.` prefix |
489494| `@data file.json` (keys as `$key`) | `@data $name file.json` (keys as `$name.key`) |
crates/htmlang-core/src/parser.rs+29−14
@@ -748,6 +748,24 @@ impl Evaluator {
748748
749749 // --- @each loop ---
750750
751+ // @raw: its indented body, or the rest of the line, verbatim
752+ if content == "@raw" {
753+ let text = block_text(children);
754+ let text = text.trim_end_matches('\n');
755+ return Ok((!text.is_empty()).then(|| vec![Node::Raw(text.to_string())]));
756+ }
757+ if let Some(rest) = content.strip_prefix("@raw ") {
758+ if rest.starts_with("\"\"\"") {
759+ return Err(ParseError {
760+ line: line_num,
761+ message: "`@raw \"\"\"` was removed: put the content in an indented block \
762+ under `@raw` (run `htmlang upgrade`)"
763+ .to_string(),
764+ });
765+ }
766+ return Ok(Some(vec![Node::Raw(rest.to_string())]));
767+ }
768+
751769 // --- Function call ---
752770
753771 if content.starts_with('@') {
@@ -993,20 +1011,17 @@ impl Evaluator {
9931011 .iter()
9941012 .partition(|node| matches!(node, Syntax::Line { text, .. } if text == "@style"));
9951013 if !style.is_empty() {
996− let mut scoped_css = String::new();
997− for node in style {
998− if let Syntax::Line { children, .. } = node {
999− for_each_line(children, &mut |node| {
1000− if let Syntax::Line { text, .. } = node
1001− && !text.trim().is_empty()
1002− {
1003− scoped_css.push_str(&format!(".hl-{} {}\n", name, text.trim()));
1004− }
1005− });
1006− }
1007− }
1008− if !scoped_css.is_empty() {
1009− ctx.custom_css.push(scoped_css);
1014+ let css: String = style
1015+ .iter()
1016+ .filter_map(|node| match node {
1017+ Syntax::Line { children, .. } => Some(block_text(children)),
1018+ _ => None,
1019+ })
1020+ .collect();
1021+ // Nested under the scope class, so any CSS works (multi-line
1022+ // rules, at-rules)
1023+ if !css.trim().is_empty() {
1024+ ctx.custom_css.push(format!(".hl-{} {{\n{}}}", name, css));
10101025 }
10111026 ctx.scoped_functions.insert(name.to_string());
10121027 }
crates/htmlang-core/src/syntax.rs+41−69
@@ -1,8 +1,8 @@
11//! Parsing: source text to a syntax tree, before anything is evaluated.
22//!
33//! This step knows the shape of the language: indentation, continuation
4−//! lines of attribute lists, verbatim blocks (`@raw """…"""`, the bodies of
5−//! `@markdown` and `@script`), multi-line strings, and the directives whose
4+//! lines of attribute lists, verbatim bodies (of `@raw`, `@style`, `@head`,
5+//! `@script` and `@markdown`), multi-line strings, and the directives whose
66//! structure spans several lines (`@if` / `@else` chains, `@each` with an
77//! `@else`, function definitions). It reads no files and substitutes no
88//! variables; `parser.rs` evaluates the tree.
@@ -101,13 +101,11 @@ impl Syntax {
101101pub(crate) fn parse(input: &str) -> Vec<Syntax> {
102102 let lines = preprocess(input);
103103 let mut pos = 0;
104− build(&lines, &mut pos, None, false)
104+ build(&lines, &mut pos, None)
105105}
106106
107107/// Build the nodes for the lines from `pos` indented deeper than `parent`.
108−/// A `plain` block is text (the body of `@style` or `@head`),
109−/// so no directive in it is given structure.
110−fn build(lines: &[Line], pos: &mut usize, parent: Option<usize>, plain: bool) -> Vec<Syntax> {
108+fn build(lines: &[Line], pos: &mut usize, parent: Option<usize>) -> Vec<Syntax> {
111109 let mut nodes = Vec::new();
112110 let deeper = |line: &Line| parent.is_none_or(|p| line.indent > p);
113111 while *pos < lines.len() && deeper(&lines[*pos]) {
@@ -125,7 +123,7 @@ fn build(lines: &[Line], pos: &mut usize, parent: Option<usize>, plain: bool) ->
125123 };
126124 // Text and one-line directives take no body: lines indented under
127125 // them are their siblings.
128− if !plain && !takes_body(&text) {
126+ if !takes_body(&text) {
129127 nodes.push(Syntax::Line {
130128 line: line.line_num,
131129 indent: line.indent,
@@ -134,20 +132,7 @@ fn build(lines: &[Line], pos: &mut usize, parent: Option<usize>, plain: bool) ->
134132 });
135133 continue;
136134 }
137− let text_body = plain
138− || text == "@style"
139− || text == "@head"
140− || text.starts_with("@head ");
141− let children = build(lines, pos, Some(line.indent), text_body);
142− if plain {
143− nodes.push(Syntax::Line {
144− line: line.line_num,
145− indent: line.indent,
146− text,
147− children,
148− });
149− continue;
150− }
135+ let children = build(lines, pos, Some(line.indent));
151136
152137 if let Some(condition) = text.strip_prefix("@if ") {
153138 let mut branches = vec![Branch {
@@ -168,7 +153,7 @@ fn build(lines: &[Line], pos: &mut usize, parent: Option<usize>, plain: bool) ->
168153 branches.push(Branch {
169154 line: next_line,
170155 condition,
171− body: build(lines, pos, Some(line.indent), false),
156+ body: build(lines, pos, Some(line.indent)),
172157 });
173158 if last {
174159 break;
@@ -182,7 +167,7 @@ fn build(lines: &[Line], pos: &mut usize, parent: Option<usize>, plain: bool) ->
182167 let mut empty = Vec::new();
183168 if let Some((_, "@else")) = sibling(lines, *pos, line.indent) {
184169 *pos += 1;
185− empty = build(lines, pos, Some(line.indent), false);
170+ empty = build(lines, pos, Some(line.indent));
186171 }
187172 nodes.push(Syntax::Each {
188173 line: line.line_num,
@@ -274,14 +259,15 @@ pub(crate) fn preprocess(input: &str) -> Vec<Line> {
274259
275260 let indent = line.len() - line.trim_start().len();
276261
277− // Inline `@markdown` and `@script` blocks keep their bodies verbatim:
278− // in Markdown, blank lines separate paragraphs, `---` is a rule (not
279− // a comment) and code indentation matters; in JavaScript, newlines,
280− // `{...}` and `$` must reach the output untouched.
281− let verbatim_body = trimmed == "@markdown"
282− || trimmed == "@script"
262+ // The bodies of `@markdown`, `@script`, `@style`, `@head` and `@raw`
263+ // are foreign text, kept verbatim: in Markdown, blank lines separate
264+ // paragraphs and `---` is a rule (not a comment); in CSS, `--name` is
265+ // a custom property; in JavaScript, newlines, `{...}` and `$` must
266+ // reach the output untouched.
267+ let verbatim_body = ["@markdown", "@script", "@style", "@head", "@raw"].contains(&trimmed)
283268 || trimmed.starts_with("@script ")
284− || trimmed.starts_with("@script[");
269+ || trimmed.starts_with("@script[")
270+ || trimmed.starts_with("@head ");
285271 if verbatim_body {
286272 lines.push(Line {
287273 indent,
@@ -325,46 +311,30 @@ pub(crate) fn preprocess(input: &str) -> Vec<Line> {
325311 continue;
326312 }
327313
328− // Handle @raw """..."""
329− if let Some(raw_rest) = trimmed.strip_prefix("@raw") {
330− let after_raw = raw_rest.trim_start();
331− if let Some(after_open) = after_raw.strip_prefix("\"\"\"") {
332− // Single-line: @raw """content"""
333− if after_open.ends_with("\"\"\"") && after_open.len() >= 3 {
334− let content = &after_open[..after_open.len() - 3];
335− lines.push(Line {
336− indent,
337− content: LineContent::Raw(content.to_string()),
338− line_num: i + 1,
339− });
340− i += 1;
341− continue;
342− }
343−
344− // Multiline: collect until closing """
345− let mut raw_content = String::new();
346− if !after_open.is_empty() {
347− raw_content.push_str(after_open);
348− raw_content.push('\n');
349− }
314+ // The removed `@raw """…"""` form: its content is kept whole (and
315+ // not evaluated) so the evaluator can point at the indented form.
316+ if let Some(after_open) = trimmed.strip_prefix("@raw \"\"\"") {
317+ let mut content = vec![after_open.trim_end_matches("\"\"\"")];
318+ let first_line_num = i + 1;
319+ if !(after_open.len() >= 3 && after_open.ends_with("\"\"\"")) {
350320 i += 1;
351− while i < raw_lines.len() {
352− if raw_lines[i].trim() == "\"\"\"" {
353− i += 1;
354− break;
355− }
356− raw_content.push_str(raw_lines[i]);
357− raw_content.push('\n');
321+ while i < raw_lines.len() && raw_lines[i].trim() != "\"\"\"" {
322+ content.push(raw_lines[i]);
358323 i += 1;
359324 }
360−
361− lines.push(Line {
362− indent,
363− content: LineContent::Raw(raw_content.trim_end_matches('\n').to_string()),
364− line_num: i,
365− });
366− continue;
367325 }
326+ i += 1;
327+ lines.push(Line {
328+ indent,
329+ content: LineContent::Normal("@raw \"\"\"".to_string()),
330+ line_num: first_line_num,
331+ });
332+ lines.push(Line {
333+ indent: indent + 1,
334+ content: LineContent::Raw(content.join("\n")),
335+ line_num: first_line_num,
336+ });
337+ continue;
368338 }
369339
370340 // `@let name """` opens a multi-line string running to a `"""` line;
@@ -501,10 +471,12 @@ mod tests {
501471 }
502472
503473 #[test]
504− fn text_bodies_stay_plain() {
505− let tree = parse("@style\n @if x {\n a\n }\n");
474+ fn text_bodies_are_verbatim() {
475+ let tree = parse("@style\n :root {\n --brand: red;\n }\n@text x\n");
506476 let Syntax::Line { children, .. } = &tree[0] else { unreachable!() };
507− assert_eq!(kinds(children), ["line", "line"]);
477+ let [Syntax::Raw { text, .. }] = children.as_slice() else { panic!("{:?}", children) };
478+ assert_eq!(text, ":root {\n --brand: red;\n}");
479+ assert_eq!(kinds(&tree), ["line", "line"]);
508480 }
509481
510482 #[test]
editors/vscode/README.md+1−1
@@ -6,7 +6,7 @@ Syntax highlighting, snippets, and LSP-backed intelligence for [htmlang](https:/
66## Features
77
88- **Syntax highlighting** — `@` directives, `$variables`, `[attribute]` brackets,
9− `-- comments`, `@raw """..."""` blocks.
9+ `-- comments`, verbatim `@raw` / `@style` / `@script` bodies.
1010- **Diagnostics** — parse errors, missing variables/functions, unused bindings,
1111 CSS / a11y warnings, and typo suggestions.
1212- **Completion** — directives, attribute keys and values, variable names, and
examples/demo.hl+6−8
@@ -44,9 +44,8 @@
4444 }
4545
4646-- Raw HTML escape hatch
47−@raw """
48−<noscript><style>.needs-js{display:none}</style></noscript>
49−"""
47+@raw
48+ <noscript><style>.needs-js{display:none}</style></noscript>
5049
5150-- Component with default parameter + @children slot
5251@let card $title $icon=◆
@@ -128,11 +127,10 @@
128127 padding 16, rounded 8, font monospace, size 13,
129128 overflow-x auto
130129 ]
131− @raw """
132−@let button $label
133− @el [padding 12, background blue]
134− @text $label
135−"""
130+ @raw
131+ @let button $label
132+ @el [padding 12, background blue]
133+ @text $label
136134
137135 -- Control flow: @each, @else, @match, if()
138136 @titled [title Control flow]
src/bin/htmlang_lsp/docs.rs+1−1
@@ -95,7 +95,7 @@ pub(crate) const DIRECTIVES: &[Doc] = &[
9595 "@let primary #3b82f6\n@let gap = 8 * 2\n@let card [padding 20]\n@let button $label\n @el [$card] $label",
9696 ),
9797 doc("include", "Inserts another `.hl` file here, with its definitions.", "@include header.hl"),
98− doc("raw", "Pastes HTML into the output verbatim.", "@raw \"\"\"<div></div>\"\"\""),
98+ doc("raw", "Pastes HTML into the output verbatim: the rest of the line, or an indented block.", "@raw\n <div class=\"widget\"></div>"),
9999 doc("markdown", "Markdown (indented body or file), converted to HTML.", "@markdown\n # Title"),
100100 doc("style", "Raw CSS; overrides generated styles.", "@style\n .note { color: gray; }"),
101101 doc("head", "Raw HTML added to `<head>`.", "@head\n <link rel=\"icon\" href=\"f.ico\">"),
src/fmt.rs+2−2
@@ -91,8 +91,8 @@ fn is_raw_body_header(code: &str) -> bool {
9191 "@style",
9292 "@script",
9393 "@markdown",
94− "@json-ld",
9594 "@head",
95+ "@raw",
9696 ]
9797 .iter()
9898 .any(|d| code == *d || code.strip_prefix(d).is_some_and(|r| r.starts_with(' ')))
@@ -528,7 +528,7 @@ mod tests {
528528
529529 #[test]
530530 fn raw_blocks_are_verbatim() {
531− let src = "@raw \"\"\"\n<pre>\n deeply indented\n</pre>\n\"\"\"\n";
531+ let src = "@raw\n <pre>\n deeply indented\n </pre>\n";
532532 assert_eq!(format(src), src);
533533 }
534534
src/upgrade.rs+40−3
@@ -45,7 +45,7 @@ const FILTER_ALIASES: &[(&str, &str)] = &[
4545
4646/// Directives whose indented bodies are foreign text (CSS, JS, Markdown,
4747/// HTML, JSON) and must not be rewritten.
48−const VERBATIM_BODIES: &[&str] = &["@style", "@script", "@markdown", "@head"];
48+const VERBATIM_BODIES: &[&str] = &["@style", "@script", "@markdown", "@head", "@raw"];
4949
5050/// The function name a layout file becomes: `layout.hl` → `layout`,
5151/// `base.hl` → `base-layout` (so it can't shadow an element like `@main`).
@@ -174,6 +174,34 @@ pub fn upgrade(input: &str) -> Upgrade {
174174 let trimmed = line.trim();
175175 let indent = indent_of(line);
176176
177+ // `@raw """…"""` → `@raw` with the content as its indented body
178+ if let Some(after_open) = trimmed.strip_prefix("@raw \"\"\"") {
179+ let pad = " ".repeat(indent);
180+ match triple_quote_end(&lines, i) {
181+ None => out.push(format!(
182+ "{pad}@raw {}",
183+ after_open.strip_suffix("\"\"\"").unwrap_or(after_open)
184+ )),
185+ Some(end) => {
186+ out.push(format!("{pad}@raw"));
187+ let closed = end <= lines.len() && lines[end - 1].trim() == "\"\"\"";
188+ let body_end = if closed { end - 1 } else { end };
189+ let content = std::iter::once(after_open)
190+ .filter(|l| !l.is_empty())
191+ .chain(lines[i + 1..body_end].iter().copied());
192+ out.extend(content.map(|l| {
193+ if l.trim().is_empty() { String::new() } else { format!("{pad} {l}") }
194+ }));
195+ i = end;
196+ changes += 1;
197+ continue;
198+ }
199+ }
200+ changes += 1;
201+ i += 1;
202+ continue;
203+ }
204+
177205 // Copy verbatim regions unchanged.
178206 if let Some(end) = triple_quote_end(&lines, i) {
179207 out.extend(lines[i..end].iter().map(|l| l.to_string()));
@@ -1566,7 +1594,7 @@ mod tests {
15661594 );
15671595 assert_eq!(
15681596 up("@raw \"\"\"\n@let button $x\n\"\"\"\n@button [type submit] Go"),
1569− "@raw \"\"\"\n@let button $x\n\"\"\"\n@button [type=submit] Go"
1597+ "@raw\n @let button $x\n@button [type=submit] Go"
15701598 );
15711599 // Multi-line lists keep their element context.
15721600 assert_eq!(up("@input [\n type email,\n padding 8\n]"), "@input [\n type=email,\n padding 8\n]");
@@ -1648,6 +1676,15 @@ mod tests {
16481676 );
16491677 }
16501678
1679+ #[test]
1680+ fn raw_strings_become_indented_bodies() {
1681+ assert_eq!(up("@raw \"\"\"<br>\"\"\"\n"), "@raw <br>\n");
1682+ assert_eq!(
1683+ up("@el\n @raw \"\"\"\n<div>\n\n <p>x</p>\n</div>\n\"\"\"\n@text y\n"),
1684+ "@el\n @raw\n <div>\n\n <p>x</p>\n </div>\n@text y\n"
1685+ );
1686+ }
1687+
16511688 #[test]
16521689 fn keyframes_move_into_style() {
16531690 assert_eq!(
@@ -1702,7 +1739,7 @@ mod tests {
17021739
17031740 #[test]
17041741 fn verbatim_regions_are_untouched() {
1705− let src = "@script\n for (x of y) {}\n@raw \"\"\"\n@unless\n\"\"\"\n@style\n .p { }";
1742+ let src = "@script\n for (x of y) {}\n@raw\n @unless\n@style\n .p { }\n@let s \"\"\"\n@unless\n\"\"\"";
17061743 assert_eq!(up(src), src);
17071744 assert_eq!(up("@script [src app.js, defer]"), "@script [src=app.js, defer]");
17081745 }
tests/regressions.rs+18
@@ -410,3 +410,21 @@ fn lines_under_text_are_its_siblings() {
410410 );
411411 assert!(html.contains("<span>Some text</span><span>more text</span>"), "{}", html);
412412}
413+
414+#[test]
415+fn style_keeps_css_custom_properties() {
416+ let out = compile("@style\n :root {\n --brand: red;\n }\n@el [color var(--brand)] x\n");
417+ assert!(out.contains("--brand: red;"), "{}", out);
418+}
419+
420+#[test]
421+fn raw_takes_an_indented_body() {
422+ let out = compile("@raw\n <div>\n\n <p>x</p>\n </div>\n@raw <hr>\n");
423+ assert!(out.contains("<div>\n\n <p>x</p>\n</div><hr>"), "{}", out);
424+ let result = parser::parse("@raw \"\"\"<br>\"\"\"\n");
425+ assert!(
426+ result.diagnostics.iter().any(|d| d.message.contains("`@raw \"\"\"` was removed")),
427+ "{:?}",
428+ result.diagnostics
429+ );
430+}
tests/snapshots.rs+1−1
@@ -3786,7 +3786,7 @@ fn function_with_style_is_scoped() {
37863786 "@let card $title\n @style\n .t { color: red; }\n @text [class=t] $title\n@card [title Hello]\n",
37873787 );
37883788 assert!(html.contains("hl-card\"><span class=\"t\">Hello"), "{}", html);
3789− assert!(html.contains(".hl-card .t { color: red; }"), "{}", html);
3789+ assert!(html.contains(".hl-card {.t { color: red; }}"), "{}", html);
37903790}
37913791
37923792#[test]
tests/snapshots/new_directives.html+5−5
@@ -1,7 +1,7 @@
11<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>My Site</title><base href="https://example.com/"><link rel="canonical" href="https://example.com/page"><link rel="preload" href="fonts/inter.woff2" as="font" crossorigin><link rel="preload" href="fonts/mono.ttf" as="font" crossorigin><script type="application/ld+json">
2−{
3−"@context": "https://schema.org",
4−"@type": "WebPage",
5−"name": "My Site"
6−}
2+ {
3+ "@context": "https://schema.org",
4+ "@type": "WebPage",
5+ "name": "My Site"
6+ }
77</script><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;gap:10px;}.b{font-family:Inter;}}@font-face { font-family: 'Inter'; src: url('fonts/inter.woff2') format('woff2'); font-display: swap; }@font-face { font-family: 'Mono'; src: url('fonts/mono.ttf') format('truetype'); font-display: swap; }</style></head><body><div class="a"><span class="b">Hello with custom fonts</span></div></body></html>
tests/snapshots/raw_html.hl+1−1
@@ -1,5 +1,5 @@
11@page Raw HTML
22@column [spacing 10]
33 @text Before raw
4− @raw """<div class="custom">Raw HTML content</div>"""
4+ @raw <div class="custom">Raw HTML content</div>
55 @text After raw