on master help sign in
Fold @svg into @image [inline]
e28a74biko
DESIGN.md+2−1
@@ -402,7 +402,7 @@ A range counts down when its start is greater than its end.
402| `@data $name dir/*.json` | Load each file as `$name.STEM.key`; `$name` lists the stems |
402| `@data $name dir/*.json` | Load each file as `$name.STEM.key`; `$name` lists the stems |
403| `@data $name env:NAME [default]` | Read an environment variable |
403| `@data $name env:NAME [default]` | Read an environment variable |
404| `@markdown` / `@markdown file.md` | Markdown, converted to HTML |
404| `@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) |
406
406
407With `@extends`, the layout marks where content goes with `@slot name` (named
407With `@extends`, the layout marks where content goes with `@slot name` (named
408blocks) and `@children` (everything in the page outside `@slot` blocks):
408blocks) and `@children` (everything in the page outside `@slot` blocks):
@@ -472,6 +472,7 @@ removed form with its replacement.
472| `@theme` | `@let --name value` lines |
472| `@theme` | `@let --name value` lines |
473| `@json-ld`, `@font-face`, `@manifest` | Raw HTML in `@head`, CSS in `@style` |
473| `@json-ld`, `@font-face`, `@manifest` | Raw HTML in `@head`, CSS in `@style` |
474| `@breakpoint`, `@deprecated` | A media query in `@style`; nothing |
474| `@breakpoint`, `@deprecated` | A media query in `@style`; nothing |
475+| `@svg [attrs] file.svg` | `@image [inline, attrs] file.svg` |
475| `gap-x`, `gap-y`, `shadow`, `blur N`, `truncate`, `critical` | `column-gap`, `row-gap`, `box-shadow`, `filter blur(N)`, `$truncate`, nothing |
476| `gap-x`, `gap-y`, `shadow`, `blur N`, `truncate`, `critical` | `column-gap`, `row-gap`, `box-shadow`, `filter blur(N)`, `$truncate`, nothing |
476| `@with $x as y` | `@let y $x` |
477| `@with $x as y` | `@let y $x` |
477| `@layout file` | `@extends file` |
478| `@layout file` | `@extends file` |
crates/htmlang-core/src/codegen.rs−13
@@ -1354,19 +1354,6 @@ fn generate_self_closing(
1354
1354
1355 // Image optimization: auto-add loading="lazy" and decoding="async"
1355 // Image optimization: auto-add loading="lazy" and decoding="async"
1356 if elem.kind == ElementKind::Image {
1356 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− }
1370 // Responsive srcset: @image photo.jpg [responsive 400 800 1200]
1357 // Responsive srcset: @image photo.jpg [responsive 400 800 1200]
1371 let responsive_attr = elem.attrs.iter().find(|a| a.key == "responsive");
1358 let responsive_attr = elem.attrs.iter().find(|a| a.key == "responsive");
1372 if let Some(resp) = responsive_attr
1359 if let Some(resp) = responsive_attr
crates/htmlang-core/src/parser.rs+47−77
@@ -1076,81 +1076,6 @@ impl Parser {
1076 }
1076 }
1077
1077
1078
1078
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−
1154
1079
1155 // --- @data (load JSON file into variables) ---
1080 // --- @data (load JSON file into variables) ---
1156
1081
@@ -1778,7 +1703,7 @@ impl Parser {
1778
1703
1779 if content.starts_with('@') || content.starts_with('[') {
1704 if content.starts_with('@') || content.starts_with('[') {
1780 let node = self.parse_element_line(&content, current_indent, line_num, ctx)?;
1705 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)]));
1782 }
1707 }
1783
1708
1784 // --- Bare text ---
1709 // --- Bare text ---
@@ -2279,7 +2204,6 @@ const KNOWN_DIRECTIVES: &[&str] = &[
2279 "assert",
2204 "assert",
2280 "markdown",
2205 "markdown",
2281 "data",
2206 "data",
2282− "svg",
2283];
2207];
2284
2208
2285fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
2209fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
@@ -2340,6 +2264,7 @@ const REMOVED_SYNTAX: &[(&str, &str)] = &[
2340 ("@base", "use `@page [base ...] Title`"),
2264 ("@base", "use `@page [base ...] Title`"),
2341 ("@og", "use `@meta og:NAME VALUE`"),
2265 ("@og", "use `@meta og:NAME VALUE`"),
2342 ("@debug", "use `@warn`"),
2266 ("@debug", "use `@warn`"),
2267+ ("@svg", "use `@image [inline] file.svg`"),
2343 ("@match", "use `@if $x == a` / `@else if $x == b` / `@else`"),
2268 ("@match", "use `@if $x == a` / `@else if $x == b` / `@else`"),
2344 ("@case", "use `@if $x == a` / `@else if $x == b` / `@else`"),
2269 ("@case", "use `@if $x == a` / `@else if $x == b` / `@else`"),
2345 ("@default", "use `@else` in an `@if` chain"),
2270 ("@default", "use `@else` in an `@if` chain"),
@@ -3856,6 +3781,51 @@ fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
3856 result
3781 result
3857}
3782}
3858
3783
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+
3859/// The first `$name|filter` (old filter syntax) in `text`, as (name, filter).
3829/// The first `$name|filter` (old filter syntax) in `text`, as (name, filter).
3860fn old_filter_syntax(text: &str) -> Option<(String, String)> {
3830fn old_filter_syntax(text: &str) -> Option<(String, String)> {
3861 const FILTERS: &[&str] = &[
3831 const FILTERS: &[&str] = &[
src/bin/htmlang_lsp/analysis.rs+1−1
@@ -1105,7 +1105,7 @@ fn is_builtin_name(name: &str) -> bool {
1105 htmlang::parser::known_directives().contains(&name)
1105 htmlang::parser::known_directives().contains(&name)
1106 || htmlang::ast::ElementKind::from_name(name).is_some()
1106 || htmlang::ast::ElementKind::from_name(name).is_some()
1107 // Directives the parser handles outside its directive list
1107 // Directives the parser handles outside its directive list
1108− || matches!(name, "data" | "svg")
1108+ || name == "data"
1109}
1109}
1110
1110
1111#[allow(clippy::too_many_arguments)]
1111#[allow(clippy::too_many_arguments)]
src/bin/htmlang_lsp/docs.rs+1−2
@@ -112,7 +112,6 @@ pub(crate) const DIRECTIVES: &[Doc] = &[
112 doc("assert", "Fails the build when the condition is false.", "@assert $spacing == 16"),
112 doc("assert", "Fails the build when the condition is false.", "@assert $spacing == 16"),
113 doc("warn", "Emits a compile-time warning.", "@warn Remember to set a title"),
113 doc("warn", "Emits a compile-time warning.", "@warn Remember to set a title"),
114 doc("data", "Loads a JSON file as variables.", "@data site.json"),
114 doc("data", "Loads a JSON file as variables.", "@data site.json"),
115− doc("svg", "Inlines an SVG file.", "@svg [width 24] icon.svg"),
116];
115];
117
116
118/// htmlang's own attributes, plus CSS properties htmlang treats specially.
117/// htmlang's own attributes, plus CSS properties htmlang treats specially.
@@ -147,7 +146,7 @@ pub(crate) const ATTRIBUTES: &[Doc] = &[
147 doc("font", "Font family; quote a stack with commas.", "font \"Inter, sans-serif\""),
146 doc("font", "Font family; quote a stack with commas.", "font \"Inter, sans-serif\""),
148 doc("line-height", "Line height; integers of 2 or more are px.", "line-height 1.5"),
147 doc("line-height", "Line height; integers of 2 or more are px.", "line-height 1.5"),
149 doc("ordered", "On `@list`: a numbered list (`<ol>`).", "ordered"),
148 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"),
151 doc("responsive", "On `@image`: widths for a generated `srcset`.", "responsive 400 800 1200"),
150 doc("responsive", "On `@image`: widths for a generated `srcset`.", "responsive 400 800 1200"),
152];
151];
153
152
src/upgrade.rs+31
@@ -653,6 +653,28 @@ fn rewrite_directive_line(
653 ));
653 ));
654 return line.to_string();
654 return line.to_string();
655 }
655 }
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+ }
656 if let Some(rest) = trimmed.strip_prefix("@import ") {
678 if let Some(rest) = trimmed.strip_prefix("@import ") {
657 return format!("{pad}@include {}", rest);
679 return format!("{pad}@include {}", rest);
658 }
680 }
@@ -1351,6 +1373,15 @@ mod tests {
1351 assert_eq!(up("a | b $x | c"), "a | b $x | c");
1373 assert_eq!(up("a | b $x | c"), "a | b $x | c");
1352 }
1374 }
1353
1375
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+
1354 #[test]
1385 #[test]
1355 fn verbatim_regions_are_untouched() {
1386 fn verbatim_regions_are_untouched() {
1356 let src = "@script\n for (x of y) {}\n@raw \"\"\"\n@unless\n\"\"\"\n@style\n .p { }";
1387 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() {
222 r#"<svg viewBox="0 0 24 24"><path stroke-width="2" d="M0 0"/></svg>"#,
222 r#"<svg viewBox="0 0 24 24"><path stroke-width="2" d="M0 0"/></svg>"#,
223 )
223 )
224 .unwrap();
224 .unwrap();
225− let out = compile_in(&dir, "@svg [width 48] icon.svg");
225+ let out = compile_in(&dir, "@image [inline, width 48] icon.svg");
226 assert!(out.contains("stroke-width=\"2\""), "{}", out);
226 assert!(out.contains("stroke-width=\"2\""), "{}", out);
227 assert!(out.contains("width=\"48\""), "{}", out);
227 assert!(out.contains("width=\"48\""), "{}", out);
228}
228}
@@ -401,3 +401,12 @@ fn lint_adds_only_checks_the_compiler_does_not_already_make() {
401 assert!(messages.iter().any(|m| m.contains("empty container")), "{:?}", messages);
401 assert!(messages.iter().any(|m| m.contains("empty container")), "{:?}", messages);
402 assert!(messages.iter().any(|m| m.contains("'type'")), "{:?}", messages);
402 assert!(messages.iter().any(|m| m.contains("'type'")), "{:?}", messages);
403}
403}
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() {
4788 let svg_path = dir.join("test.svg");
4788 let svg_path = dir.join("test.svg");
4789 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();
4789 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();
4790
4790
4791− let input = format!("@svg {}", svg_path.display());
4791+ let input = format!("@image [inline] {}", svg_path.display());
4792 let result = htmlang::parser::parse(&input);
4792 let result = htmlang::parser::parse(&input);
4793 assert!(
4793 assert!(
4794 result
4794 result
@@ -4824,7 +4824,7 @@ fn svg_directive_with_attrs() {
4824 )
4824 )
4825 .unwrap();
4825 .unwrap();
4826
4826
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());
4828 let result = htmlang::parser::parse(&input);
4828 let result = htmlang::parser::parse(&input);
4829 let html = htmlang::codegen::generate(&result.document);
4829 let html = htmlang::codegen::generate(&result.document);
4830 assert!(
4830 assert!(
@@ -4843,7 +4843,7 @@ fn svg_directive_with_attrs() {
4843
4843
4844#[test]
4844#[test]
4845fn svg_directive_missing_file() {
4845fn 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");
4847 let errors: Vec<_> = result
4847 let errors: Vec<_> = result
4848 .diagnostics
4848 .diagnostics
4849 .iter()
4849 .iter()