on master help sign in
Fold @svg into @image [inline]

The two did the same thing, but @image [inline] read the file relative to the working directory and dropped its attributes. It now does what @svg did: resolves the path from the page and applies width, height, color/fill, class= and id= to the <svg> tag. htmlang upgrade rewrites @svg [attrs] file.svg to @image [inline, attrs] file.svg.

commite28a74bdfed101c8d752ff3c7c4d73f7ad5b6536 authoriko <mail@iko.soy> dateSat 26 Sep 2026, 02:27 (2 weeks ago) parentbea58d4
8 files changed+95−98
DESIGN.md+2−1 crates/htmlang-core/src/codegen.rs−13 crates/htmlang-core/src/parser.rs+47−77 src/bin/htmlang_lsp/analysis.rs+1−1 src/bin/htmlang_lsp/docs.rs+1−2 src/upgrade.rs+31 tests/regressions.rs+10−1 tests/snapshots.rs+3−3
DESIGN.md+2−1
@@ -402,7 +402,7 @@ A range counts down when its start is greater than its end.
402402| `@data $name dir/*.json` | Load each file as `$name.STEM.key`; `$name` lists the stems |
403403| `@data $name env:NAME [default]` | Read an environment variable |
404404| `@markdown` / `@markdown file.md` | Markdown, converted to HTML |
405−| `@svg file.svg` | Inline an SVG file |
405+| `@image [inline] file.svg` | Inline an SVG file (`width`, `height`, `color`, `class=`, `id=` apply to it) |
406406
407407With `@extends`, the layout marks where content goes with `@slot name` (named
408408blocks) and `@children` (everything in the page outside `@slot` blocks):
@@ -472,6 +472,7 @@ removed form with its replacement.
472472| `@theme` | `@let --name value` lines |
473473| `@json-ld`, `@font-face`, `@manifest` | Raw HTML in `@head`, CSS in `@style` |
474474| `@breakpoint`, `@deprecated` | A media query in `@style`; nothing |
475+| `@svg [attrs] file.svg` | `@image [inline, attrs] file.svg` |
475476| `gap-x`, `gap-y`, `shadow`, `blur N`, `truncate`, `critical` | `column-gap`, `row-gap`, `box-shadow`, `filter blur(N)`, `$truncate`, nothing |
476477| `@with $x as y` | `@let y $x` |
477478| `@layout file` | `@extends file` |
crates/htmlang-core/src/codegen.rs−13
@@ -1354,19 +1354,6 @@ fn generate_self_closing(
13541354
13551355 // Image optimization: auto-add loading="lazy" and decoding="async"
13561356 if elem.kind == ElementKind::Image {
1357− // SVG inlining: @image [inline] logo.svg
1358− if elem.attrs.iter().any(|a| a.key == "inline")
1359− && let Some(src) = &elem.argument
1360− && src.ends_with(".svg")
1361− && let Ok(svg_content) = std::fs::read_to_string(src)
1362− {
1363− // Close the tag we opened, then emit inline SVG instead
1364− out.truncate(out.rfind('<').unwrap_or(0));
1365− out.push_str(&ctx.indent());
1366− out.push_str(svg_content.trim());
1367− out.push_str(ctx.nl());
1368− return;
1369− }
13701357 // Responsive srcset: @image photo.jpg [responsive 400 800 1200]
13711358 let responsive_attr = elem.attrs.iter().find(|a| a.key == "responsive");
13721359 if let Some(resp) = responsive_attr
crates/htmlang-core/src/parser.rs+47−77
@@ -1076,81 +1076,6 @@ impl Parser {
10761076 }
10771077
10781078
1079− // --- @svg (inline SVG from file) ---
1080−
1081− if let Some(rest) = content.strip_prefix("@svg ") {
1082− let rest = rest.trim();
1083− // @svg file.svg OR @svg [attrs] file.svg
1084− let (attrs_part, filename) = if rest.starts_with('[') {
1085− if let Some(bracket_end) = rest.find(']') {
1086− let attrs_str = &rest[..=bracket_end];
1087− let file = rest[bracket_end + 1..].trim();
1088− (Some(attrs_str.to_string()), file.to_string())
1089− } else {
1090− (None, rest.to_string())
1091− }
1092− } else {
1093− (None, rest.to_string())
1094− };
1095−
1096− let filename = substitute_vars(&filename, &ctx.variables);
1097− let resolved = match &ctx.base_path {
1098− Some(base) => base.join(&filename),
1099− None => PathBuf::from(&filename),
1100− };
1101−
1102− match std::fs::read_to_string(&resolved) {
1103− Ok(svg_content) => {
1104− let mut svg = svg_content.trim().to_string();
1105− // Apply attributes (width, height, color/fill, class)
1106− if let Some(ref attrs_str) = attrs_part {
1107− let (attrs, _) = parse_attr_brackets(attrs_str, line_num, ctx)?;
1108− for attr in &attrs {
1109− match attr.key.as_str() {
1110− "width" => {
1111− if let Some(ref val) = attr.value {
1112− svg = set_svg_attr(&svg, "width", val);
1113− }
1114− }
1115− "height" => {
1116− if let Some(ref val) = attr.value {
1117− svg = set_svg_attr(&svg, "height", val);
1118− }
1119− }
1120− "color" | "fill" => {
1121− if let Some(ref val) = attr.value {
1122− svg = set_svg_attr(&svg, "fill", val);
1123− }
1124− }
1125− "class" => {
1126− if let Some(ref val) = attr.value {
1127− svg = set_svg_attr(&svg, "class", val);
1128− }
1129− }
1130− "id" => {
1131− if let Some(ref val) = attr.value {
1132− svg = set_svg_attr(&svg, "id", val);
1133− }
1134− }
1135− _ => {}
1136− }
1137− }
1138− }
1139− return Ok(Some(vec![Node::Raw(svg)]));
1140− }
1141− Err(e) => {
1142− ctx.diagnostics.push(Diagnostic {
1143− line: line_num,
1144− column: None,
1145− message: format!("cannot load SVG '{}': {}", filename, e),
1146− severity: Severity::Error,
1147− source_line: Some(content.clone()),
1148− });
1149− return Ok(None);
1150− }
1151− }
1152− }
1153−
11541079
11551080 // --- @data (load JSON file into variables) ---
11561081
@@ -1778,7 +1703,7 @@ impl Parser {
17781703
17791704 if content.starts_with('@') || content.starts_with('[') {
17801705 let node = self.parse_element_line(&content, current_indent, line_num, ctx)?;
1781− return Ok(Some(vec![node]));
1706+ return Ok(Some(vec![inline_svg(node, line_num, ctx)]));
17821707 }
17831708
17841709 // --- Bare text ---
@@ -2279,7 +2204,6 @@ const KNOWN_DIRECTIVES: &[&str] = &[
22792204 "assert",
22802205 "markdown",
22812206 "data",
2282− "svg",
22832207];
22842208
22852209fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
@@ -2340,6 +2264,7 @@ const REMOVED_SYNTAX: &[(&str, &str)] = &[
23402264 ("@base", "use `@page [base ...] Title`"),
23412265 ("@og", "use `@meta og:NAME VALUE`"),
23422266 ("@debug", "use `@warn`"),
2267+ ("@svg", "use `@image [inline] file.svg`"),
23432268 ("@match", "use `@if $x == a` / `@else if $x == b` / `@else`"),
23442269 ("@case", "use `@if $x == a` / `@else if $x == b` / `@else`"),
23452270 ("@default", "use `@else` in an `@if` chain"),
@@ -3856,6 +3781,51 @@ fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
38563781 result
38573782}
38583783
3784+/// `@image [inline] icon.svg` becomes the SVG's markup, with `width`,
3785+/// `height`, `color` / `fill`, `class=` and `id=` applied to the `<svg>`
3786+/// tag. Other nodes are returned unchanged.
3787+fn inline_svg(node: Node, line_num: usize, ctx: &mut ParseContext) -> Node {
3788+ let Node::Element(elem) = &node else {
3789+ return node;
3790+ };
3791+ let is_inline_svg = elem.kind == ElementKind::Image
3792+ && elem.attrs.iter().any(|a| a.key == "inline" && !a.html)
3793+ && elem.argument.as_deref().is_some_and(|src| src.ends_with(".svg"));
3794+ if !is_inline_svg {
3795+ return node;
3796+ }
3797+ let filename = elem.argument.clone().unwrap_or_default();
3798+ let resolved = match &ctx.base_path {
3799+ Some(base) => base.join(&filename),
3800+ None => PathBuf::from(&filename),
3801+ };
3802+ let mut svg = match std::fs::read_to_string(&resolved) {
3803+ Ok(text) => text.trim().to_string(),
3804+ Err(e) => {
3805+ ctx.diagnostics.push(Diagnostic {
3806+ line: line_num,
3807+ column: None,
3808+ message: format!("cannot load SVG '{}': {}", filename, e),
3809+ severity: Severity::Error,
3810+ source_line: None,
3811+ });
3812+ return node;
3813+ }
3814+ };
3815+ ctx.included_files.push(resolved);
3816+ for attr in &elem.attrs {
3817+ let Some(value) = &attr.value else { continue };
3818+ let target = match (attr.key.as_str(), attr.html) {
3819+ ("width", false) | ("height", false) => attr.key.as_str(),
3820+ ("color", false) | ("fill", false) => "fill",
3821+ ("class", true) | ("id", true) => attr.key.as_str(),
3822+ _ => continue,
3823+ };
3824+ svg = set_svg_attr(&svg, target, value);
3825+ }
3826+ Node::Raw(svg)
3827+}
3828+
38593829/// The first `$name|filter` (old filter syntax) in `text`, as (name, filter).
38603830fn old_filter_syntax(text: &str) -> Option<(String, String)> {
38613831 const FILTERS: &[&str] = &[
src/bin/htmlang_lsp/analysis.rs+1−1
@@ -1105,7 +1105,7 @@ fn is_builtin_name(name: &str) -> bool {
11051105 htmlang::parser::known_directives().contains(&name)
11061106 || htmlang::ast::ElementKind::from_name(name).is_some()
11071107 // Directives the parser handles outside its directive list
1108− || matches!(name, "data" | "svg")
1108+ || name == "data"
11091109}
11101110
11111111#[allow(clippy::too_many_arguments)]
src/bin/htmlang_lsp/docs.rs+1−2
@@ -112,7 +112,6 @@ pub(crate) const DIRECTIVES: &[Doc] = &[
112112 doc("assert", "Fails the build when the condition is false.", "@assert $spacing == 16"),
113113 doc("warn", "Emits a compile-time warning.", "@warn Remember to set a title"),
114114 doc("data", "Loads a JSON file as variables.", "@data site.json"),
115− doc("svg", "Inlines an SVG file.", "@svg [width 24] icon.svg"),
116115];
117116
118117/// htmlang's own attributes, plus CSS properties htmlang treats specially.
@@ -147,7 +146,7 @@ pub(crate) const ATTRIBUTES: &[Doc] = &[
147146 doc("font", "Font family; quote a stack with commas.", "font \"Inter, sans-serif\""),
148147 doc("line-height", "Line height; integers of 2 or more are px.", "line-height 1.5"),
149148 doc("ordered", "On `@list`: a numbered list (`<ol>`).", "ordered"),
150− doc("inline", "On `@image` or `@svg`: embed the file in the page.", "inline"),
149+ doc("inline", "On `@image`: embed the file (SVG markup, or other images as base64) in the page.", "inline"),
151150 doc("responsive", "On `@image`: widths for a generated `srcset`.", "responsive 400 800 1200"),
152151];
153152
src/upgrade.rs+31
@@ -653,6 +653,28 @@ fn rewrite_directive_line(
653653 ));
654654 return line.to_string();
655655 }
656+ // @svg [attrs] file.svg → @image [inline, attrs] file.svg
657+ if let Some(rest) = trimmed.strip_prefix("@svg ") {
658+ let rest = rest.trim();
659+ let (attrs, file) = match rest.strip_prefix('[').and_then(|r| r.split_once(']')) {
660+ Some((attrs, file)) => (attrs.trim(), file.trim()),
661+ None => ("", rest),
662+ };
663+ let attrs: Vec<String> = split_top_level_commas(attrs)
664+ .into_iter()
665+ .map(str::trim)
666+ .filter(|a| !a.is_empty())
667+ .map(|a| match a.split_once(' ') {
668+ Some((key @ ("class" | "id"), value)) => format!("{}={}", key, value.trim()),
669+ _ => a.to_string(),
670+ })
671+ .collect();
672+ let list = std::iter::once("inline".to_string())
673+ .chain(attrs)
674+ .collect::<Vec<_>>()
675+ .join(", ");
676+ return format!("{pad}@image [{}] {}", list, file);
677+ }
656678 if let Some(rest) = trimmed.strip_prefix("@import ") {
657679 return format!("{pad}@include {}", rest);
658680 }
@@ -1351,6 +1373,15 @@ mod tests {
13511373 assert_eq!(up("a | b $x | c"), "a | b $x | c");
13521374 }
13531375
1376+ #[test]
1377+ fn svg_becomes_inline_image() {
1378+ assert_eq!(up("@svg icons/a.svg"), "@image [inline] icons/a.svg");
1379+ assert_eq!(
1380+ up("@svg [width 24, color red, class icon] a.svg"),
1381+ "@image [inline, width 24, color red, class=icon] a.svg"
1382+ );
1383+ }
1384+
13541385 #[test]
13551386 fn verbatim_regions_are_untouched() {
13561387 let src = "@script\n for (x of y) {}\n@raw \"\"\"\n@unless\n\"\"\"\n@style\n .p { }";
tests/regressions.rs+10−1
@@ -222,7 +222,7 @@ fn svg_width_does_not_touch_stroke_width() {
222222 r#"<svg viewBox="0 0 24 24"><path stroke-width="2" d="M0 0"/></svg>"#,
223223 )
224224 .unwrap();
225− let out = compile_in(&dir, "@svg [width 48] icon.svg");
225+ let out = compile_in(&dir, "@image [inline, width 48] icon.svg");
226226 assert!(out.contains("stroke-width=\"2\""), "{}", out);
227227 assert!(out.contains("width=\"48\""), "{}", out);
228228}
@@ -401,3 +401,12 @@ fn lint_adds_only_checks_the_compiler_does_not_already_make() {
401401 assert!(messages.iter().any(|m| m.contains("empty container")), "{:?}", messages);
402402 assert!(messages.iter().any(|m| m.contains("'type'")), "{:?}", messages);
403403}
404+
405+#[test]
406+fn inline_svg_resolves_from_the_page_and_keeps_attributes() {
407+ let dir = scratch_dir("inline_svg");
408+ std::fs::create_dir_all(dir.join("icons")).unwrap();
409+ std::fs::write(dir.join("icons/a.svg"), r#"<svg viewBox="0 0 24 24"></svg>"#).unwrap();
410+ let out = compile_in(&dir, "@image [inline, width 24, class=icon] icons/a.svg");
411+ assert!(out.contains(r#"<svg viewBox="0 0 24 24" width="24" class="icon">"#), "{}", out);
412+}
tests/snapshots.rs+3−3
@@ -4788,7 +4788,7 @@ fn svg_directive_inline() {
47884788 let svg_path = dir.join("test.svg");
47894789 std::fs::write(&svg_path, r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>"#).unwrap();
47904790
4791− let input = format!("@svg {}", svg_path.display());
4791+ let input = format!("@image [inline] {}", svg_path.display());
47924792 let result = htmlang::parser::parse(&input);
47934793 assert!(
47944794 result
@@ -4824,7 +4824,7 @@ fn svg_directive_with_attrs() {
48244824 )
48254825 .unwrap();
48264826
4827− let input = format!("@svg [width 24, color red] {}", svg_path.display());
4827+ let input = format!("@image [inline, width 24, color red] {}", svg_path.display());
48284828 let result = htmlang::parser::parse(&input);
48294829 let html = htmlang::codegen::generate(&result.document);
48304830 assert!(
@@ -4843,7 +4843,7 @@ fn svg_directive_with_attrs() {
48434843
48444844#[test]
48454845fn svg_directive_missing_file() {
4846− let result = htmlang::parser::parse("@svg /nonexistent/missing.svg");
4846+ let result = htmlang::parser::parse("@image [inline] /nonexistent/missing.svg");
48474847 let errors: Vec<_> = result
48484848 .diagnostics
48494849 .iter()