on master help sign in
Drop """ strings

Triple quotes were the language's last quoting construct besides "...". Multi-line content is a function whose body is text, used as @name.

commitb57697875391c36c129785c87f0174adf52b5e1c authoriko <mail@iko.soy> dateSat 26 Sep 2026, 03:46 (2 weeks ago) parent86bef11
7 files changed+15−156
DESIGN.md+7−6 crates/htmlang-core/src/parser.rs+1−13 crates/htmlang-core/src/syntax.rs+1−75 editors/vscode/syntaxes/htmlang.tmLanguage.json−22 src/fmt.rs+2−23 tests/regressions.rs+4−11 tests/snapshots.rs−6
DESIGN.md+7−6
@@ -318,14 +318,15 @@ A function is called like an element:
318318@note [kind Tip, padding 20] Scoped styles and forwarded attributes.
319319```
320320
321−A multi-line string uses triple quotes; its indented lines are the value:
321+Multi-line content is a function whose body is text:
322322
323323```
324−@let intro """
325− First line
326− Second line
327− """
328−@text $intro
324+@let intro
325+ htmlang is a layout language.
326+ It compiles to {@text [font-weight bold] static HTML}.
327+
328+@paragraph
329+ @intro
329330```
330331
331332`@let --name value` also declares the CSS custom property `--name` on
crates/htmlang-core/src/parser.rs+1−13
@@ -392,11 +392,7 @@ impl Evaluator {
392392 return Ok(None);
393393 }
394394
395− // Triple quotes: @let name """...""" (syntax.rs joins the
396− // lines of a multi-line string)
397− let value = if let Some(after_open) = value.strip_prefix("\"\"\"") {
398− after_open.strip_suffix("\"\"\"").unwrap_or(after_open)
399− } else if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
395+ let value = if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
400396 // Support quoted string interpolation: @let greeting "Hello $name"
401397 &value[1..value.len() - 1]
402398 } else {
@@ -747,14 +743,6 @@ impl Evaluator {
747743 return Ok((!text.is_empty()).then(|| vec![Node::Raw(text.to_string())]));
748744 }
749745 if let Some(rest) = content.strip_prefix("@raw ") {
750− if rest.starts_with("\"\"\"") {
751− return Err(ParseError {
752− line: line_num,
753− message: "`@raw \"\"\"` was removed: put the content in an indented block \
754− under `@raw` (run `htmlang upgrade`)"
755− .to_string(),
756− });
757− }
758746 return Ok(Some(vec![Node::Raw(rest.to_string())]));
759747 }
760748
crates/htmlang-core/src/syntax.rs+1−75
@@ -2,7 +2,7 @@
22//!
33//! This step knows the shape of the language: indentation, continuation
44//! lines of attribute lists, verbatim bodies (of `@raw`, `@style`, `@head`,
5−//! `@script` and `@markdown`), multi-line strings, and the directives whose
5+//! `@script` and `@markdown`), 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.
@@ -230,19 +230,6 @@ fn sibling(lines: &[Line], pos: usize, indent: usize) -> Option<(usize, &str)> {
230230 }
231231}
232232
233−/// For `@let name """…` (or `@let name = """…`) without the closing `"""`
234−/// on the same line, the text after the opening quotes.
235−fn opens_multiline_string(line: &str) -> Option<&str> {
236− let (_, value) = line.strip_prefix("@let ")?.trim_start().split_once(' ')?;
237− let value = value.trim_start();
238− let value = value.strip_prefix('=').map_or(value, str::trim_start);
239− let open = value.strip_prefix("\"\"\"")?;
240− if open.len() >= 3 && open.ends_with("\"\"\"") {
241− return None;
242− }
243− Some(open)
244−}
245−
246233pub(crate) fn preprocess(input: &str) -> Vec<Line> {
247234 let raw_lines: Vec<&str> = input.lines().collect();
248235 let mut lines = Vec::new();
@@ -311,60 +298,6 @@ pub(crate) fn preprocess(input: &str) -> Vec<Line> {
311298 continue;
312299 }
313300
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("\"\"\"")) {
320− i += 1;
321− while i < raw_lines.len() && raw_lines[i].trim() != "\"\"\"" {
322− content.push(raw_lines[i]);
323− i += 1;
324− }
325− }
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;
338− }
339−
340− // `@let name """` opens a multi-line string running to a `"""` line;
341− // it becomes one line holding the whole value.
342− if let Some(open) = opens_multiline_string(trimmed) {
343− let mut value: Vec<&str> = Vec::new();
344− if !open.is_empty() {
345− value.push(open);
346− }
347− let first_line_num = i + 1;
348− i += 1;
349− while i < raw_lines.len() {
350− let next = raw_lines[i].trim();
351− i += 1;
352− if next == "\"\"\"" {
353− break;
354− }
355− if !next.is_empty() {
356− value.push(next);
357− }
358− }
359− let head = &trimmed[..trimmed.len() - open.len()];
360− lines.push(Line {
361− indent,
362− content: LineContent::Normal(format!("{}{}\"\"\"", head, value.join("\n"))),
363− line_num: first_line_num,
364− });
365− continue;
366− }
367−
368301 // Join continuation lines for multi-line attribute brackets
369302 let first_line_num = i + 1;
370303 let mut full = trimmed.to_string();
@@ -478,11 +411,4 @@ mod tests {
478411 assert_eq!(text, ":root {\n --brand: red;\n}");
479412 assert_eq!(kinds(&tree), ["line", "line"]);
480413 }
481−
482− #[test]
483− fn multiline_strings_become_one_line() {
484− let tree = parse("@let msg \"\"\"\n Hello\n there\n\"\"\"\n@text $msg\n");
485− assert_eq!(kinds(&tree), ["line", "line"]);
486− assert_eq!(tree[0].source(), "@let msg \"\"\"Hello\nthere\"\"\"");
487− }
488414}
editors/vscode/syntaxes/htmlang.tmLanguage.json−22
@@ -6,9 +6,6 @@
66 {
77 "include": "#comment"
88 },
9− {
10− "include": "#raw-block"
11− },
129 {
1310 "include": "#page-directive"
1411 },
@@ -45,25 +42,6 @@
4542 "name": "comment.line.double-dash.htmlang",
4643 "match": "^\\s*--.*$"
4744 },
48− "raw-block": {
49− "name": "meta.raw.htmlang",
50− "begin": "(@raw)\\s+(\"\"\")",
51− "end": "(\"\"\")",
52− "beginCaptures": {
53− "1": {
54− "name": "entity.name.tag.htmlang"
55− },
56− "2": {
57− "name": "punctuation.definition.string.begin.htmlang"
58− }
59− },
60− "endCaptures": {
61− "1": {
62− "name": "punctuation.definition.string.end.htmlang"
63− }
64− },
65− "contentName": "string.unquoted.raw.htmlang"
66− },
6745 "page-directive": {
6846 "match": "(@page)\\s+(.*)",
6947 "captures": {
src/fmt.rs+2−23
@@ -151,8 +151,8 @@ fn split_trailing_comment(line: &str) -> (&str, Option<&str>) {
151151}
152152
153153/// Format an htmlang source file with normalized indentation (2 spaces per level)
154−/// and cleaned-up whitespace. Bodies of `"""` strings and raw-content directives
155−/// (`@style`, `@script`, ...) are kept verbatim apart from a uniform shift.
154+/// and cleaned-up whitespace. Bodies of raw-content directives (`@style`,
155+/// `@script`, ...) are kept verbatim apart from a uniform shift.
156156pub fn format(input: &str) -> String {
157157 let mut output = String::new();
158158 let mut indent_stack: Vec<i32> = vec![-1]; // sentinel
@@ -165,23 +165,12 @@ pub fn format(input: &str) -> String {
165165 let mut bracket_has_comment = false;
166166 let mut bracket_delta: i32 = 0;
167167 let mut pending_comment: Option<String> = None;
168− // Inside a `"""` string: copy lines verbatim until the closing `"""`.
169− let mut in_triple_quote = false;
170168 // Inside a raw-content body: (header raw indent, indent delta).
171169 let mut raw_body: Option<(i32, i32)> = None;
172170
173171 for line in input.lines() {
174172 let trimmed = line.trim();
175173
176− if in_triple_quote {
177− output.push_str(line);
178− output.push('\n');
179− if trimmed.contains("\"\"\"") {
180− in_triple_quote = false;
181− }
182− continue;
183− }
184−
185174 if let Some((header_indent, delta)) = raw_body {
186175 if trimmed.is_empty() {
187176 output.push('\n');
@@ -290,16 +279,6 @@ pub fn format(input: &str) -> String {
290279 let level = indent_stack.len() - 1;
291280 let is_code = trimmed.starts_with('@');
292281
293− // An opening `"""` without its closing partner starts a verbatim block.
294− if trimmed.matches("\"\"\"").count() % 2 == 1 {
295− output.push_str(&" ".repeat(level));
296− output.push_str(trimmed);
297− output.push('\n');
298− indent_stack.push(raw_indent);
299− in_triple_quote = true;
300− continue;
301− }
302−
303282 // Split off a trailing `-- comment` before doing bracket math.
304283 let (code, trailing) = split_trailing_comment(trimmed);
305284 let code = code.trim_end();
tests/regressions.rs+4−11
@@ -172,11 +172,10 @@ fn multiline_attributes_still_join() {
172172}
173173
174174#[test]
175−fn triple_quoted_let_is_a_string_not_a_function() {
176− let out = compile("@let msg \"\"\"\n hello\n world\n \"\"\"\n@text $msg");
177− assert!(out.contains("hello"), "{}", out);
178− assert!(!out.contains("\"\"\""), "{}", out);
179− assert!(!out.contains("$msg"), "{}", out);
175+fn multi_line_content_is_a_function() {
176+ let out = compile("@let intro\n First line\n Second {@text [font-weight bold] line}\n@paragraph\n @intro\n");
177+ assert!(out.contains("First line"), "{}", out);
178+ assert!(out.contains(">line</span>"), "{}", out);
180179}
181180
182181#[test]
@@ -421,12 +420,6 @@ fn style_keeps_css_custom_properties() {
421420fn raw_takes_an_indented_body() {
422421 let out = compile("@raw\n <div>\n\n <p>x</p>\n </div>\n@raw <hr>\n");
423422 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− );
430423}
431424
432425#[test]
tests/snapshots.rs−6
@@ -954,12 +954,6 @@ fn ternary_expression_false() {
954954 assert!(output.contains("color:gray"));
955955}
956956
957−#[test]
958−fn multiline_let_triple_quotes() {
959− let output = compile("@page T\n@let bio \"\"\"Hello World\"\"\"\n@text $bio");
960− assert!(output.contains("Hello World"));
961−}
962−
963957#[test]
964958fn comparison_operators_gt() {
965959 let output = compile("@page T\n@let count 5\n@if $count > 3\n @text big");