on master help sign in
Remove directives that duplicate others

- @match / @case / @default -> @if $x == a / @else if / @else - @theme -> @let --name value lines (a primary token also gets @meta theme-color, as before) - @json-ld -> a <script type="application/ld+json"> in @head - @font-face -> the rule in @style (plus the preload link it added) - @manifest -> a manifest.json linked from @head - @breakpoint -> a media query in @style (its custom prefixes were parsed but never applied) - @deprecated -> removed htmlang upgrade rewrites all of these except @manifest and @breakpoint, which it reports. The AST loses its theme, font, JSON-LD, manifest and breakpoint fields.

commitb2940e556602b947b4633ae151ccdc7e26e690c8 authoriko <mail@iko.soy> dateSat 26 Sep 2026, 02:10 (2 weeks ago) parent7be4fc3
14 files changed+238−561
DESIGN.md+19−26 README.md+1−1 crates/htmlang-core/src/ast.rs−17 crates/htmlang-core/src/codegen.rs+3−160 crates/htmlang-core/src/codegen_tests.rs+1−7 crates/htmlang-core/src/parser.rs+15−274 examples/demo.hl+17−14 src/upgrade.rs+135−1 tests/snapshots.rs+18−36 tests/snapshots/manifest_directive.hl+2−7 tests/snapshots/manifest_directive.html+1−1 tests/snapshots/match_directive.hl+9−10 tests/snapshots/new_directives.hl+13−5 tests/snapshots/new_directives.html+4−2
DESIGN.md+19−26
@@ -243,8 +243,6 @@ Prefix a style attribute to apply it conditionally:
243243| `cq-sm:` … `cq-2xl:` | Container queries |
244244| `dark:`, `print:`, `motion-safe:`, `motion-reduce:`, `landscape:`, `portrait:` | Media conditions |
245245
246−`@breakpoint name WIDTH` defines a custom responsive prefix.
247−
248246```
249247@el [padding 16, background #3b82f6, hover:background #2563eb, md:padding 32, dark:background #1e3a8a]
250248 @text [color white] Click me
@@ -324,14 +322,12 @@ A multi-line string uses triple quotes; its indented lines are the value:
324322@text $intro
325323```
326324
327−`@let --name value` also emits a CSS custom property. `@theme` declares a group
328−of design tokens at once; each becomes both `$name` and `--name`:
325+`@let --name value` also emits a CSS custom property, used as `$--name` and
326+as `var(--name)` in the generated CSS:
329327
330328```
331−@theme
332− brand #3b82f6
333− radius 8
334−@el [background $brand, rounded $radius] Themed
329+@let --brand #3b82f6
330+@el [background $--brand] Themed
335331```
336332
337333### Filters
@@ -349,7 +345,7 @@ and for colors `lighten:N`, `darken:N`, `alpha:N`, `mix:COLOR:N`.
349345### Checks
350346
351347`@assert CONDITION` fails the build when false. `@warn MESSAGE` emits a
352−warning. `@deprecated MESSAGE` before a function warns every caller.
348+warning.
353349
354350## Expressions
355351
@@ -392,12 +388,6 @@ All control flow runs at compile time.
392388
393389@each $label, $url in Home /, About /about
394390 @link $url $label
395−
396−@match $count
397− @case 3
398− @text Three
399− @default
400− @text Other
401391```
402392
403393A range counts down when its start is greater than its end.
@@ -406,14 +396,12 @@ A range counts down when its start is greater than its end.
406396
407397| Directive | Effect |
408398|---|---|
409−| `@include file.hl` | Insert another file here, content and definitions |
410−| `@import file.hl` | Take only its definitions (`@let`, bundles, functions) |
399+| `@include file.hl` | Insert another file: its content and definitions (a library of `@let`s emits nothing) |
400+| `@include lib.hl as ui` | Take only its definitions, named `ui.name` |
411401| `@extends layout.hl` | Render this page inside a layout (below) |
412−| `@data file.json` / `@data $prefix file.json` | Load JSON values as variables |
413−| `@fetch $prefix http://...` | Like `@data`, fetched at build time (http only) |
414−| `@collection $name "glob"` | List files matching a pattern |
415−| `@translations` | Per-locale strings, used as `$t.key` |
416−| `@env NAME default` | Read an environment variable |
402+| `@data file.json` / `@data $name file.json` | Load JSON values as variables (`$name.key`) |
403+| `@data $name dir/*.json` | Load each file as `$name.STEM.key`; `$name` lists the stems |
404+| `@data $name env:NAME [default]` | Read an environment variable |
417405| `@markdown` / `@markdown file.md` | Markdown, converted to HTML |
418406| `@svg file.svg` | Inline an SVG file |
419407
@@ -442,8 +430,8 @@ blocks) and `@children` (everything in the page outside `@slot` blocks):
442430`@page TITLE` produces a full HTML document; without it the output is a fragment.
443431Its attributes set `lang`, `favicon`, `canonical` and `base`. `@meta NAME VALUE`
444432adds a meta tag (`og:` names become Open Graph `property` tags), and
445−`@font-face NAME URL`, `@manifest NAME` (with indented settings), `@json-ld`
446−(indented JSON) and `@head` (indented raw HTML) add the rest.
433+`@head` holds any other raw HTML for the `<head>` (fonts, JSON-LD, a manifest
434+link). Translations are a JSON file per locale: `@data $t locales/$lang.json`.
447435
448436```
449437@page [lang en, favicon /favicon.png] My Site
@@ -479,8 +467,13 @@ removed form with its replacement.
479467| `@fn`, `@define`, `@mixin`, `@component` | `@let` (function, bundle; `@style` in a function body is scoped) |
480468| `@unless COND` | `@if not COND` |
481469| `@for $i in A..B`, `@repeat N` | `@each $i in A..B`, `@each $_ in 1..N` |
482−| `@switch` | `@match` |
483−| `@use "file" names` | `@import file` |
470+| `@switch`, `@match` | `@if $x == a` / `@else if` / `@else` |
471+| `@use "file" names`, `@import file` | `@include file` |
472+| `@collection`, `@env`, `@translations`, `@fetch` | `@data $name SOURCE` (glob, `env:NAME`, or a JSON file per locale) |
473+| `@theme` | `@let --name value` lines |
474+| `@json-ld`, `@font-face`, `@manifest` | Raw HTML in `@head`, CSS in `@style` |
475+| `@breakpoint`, `@deprecated` | A media query in `@style`; nothing |
476+| `gap-x`, `gap-y`, `shadow`, `blur N`, `truncate`, `critical` | `column-gap`, `row-gap`, `box-shadow`, `filter blur(N)`, `$truncate`, nothing |
484477| `@with $x as y` | `@let y $x` |
485478| `@layout file` | `@extends file` |
486479| `@scope`, `@starting-style`, `@css-property` | The CSS rule in `@style` |
README.md+1−1
@@ -122,4 +122,4 @@ Control flow runs at compile time:
122122```
123123
124124See [DESIGN.md](DESIGN.md) for the full language: files and data (`@include`,
125−`@import`, `@extends`, `@data`), page metadata, filters, expressions and CSS.
125+`@extends`, `@data`), page metadata, filters, expressions and CSS.
crates/htmlang-core/src/ast.rs−17
@@ -14,29 +14,12 @@ pub struct Document {
1414 pub css_vars: Vec<(String, String)>,
1515 pub custom_css: Vec<String>,
1616 pub og_tags: Vec<(String, String)>,
17− pub custom_breakpoints: Vec<(String, String)>,
18− pub theme_tokens: Vec<(String, String)>,
1917 pub canonical: Option<String>,
2018 pub base_url: Option<String>,
21− pub font_faces: Vec<(String, String)>,
22− pub json_ld_blocks: Vec<String>,
23− pub manifest: Option<ManifestConfig>,
2419 pub preload_hints: Vec<PreloadHint>,
2520 pub nodes: Vec<Node>,
2621}
2722
28−#[derive(Debug, Clone)]
29−pub struct ManifestConfig {
30− pub name: String,
31− pub short_name: Option<String>,
32− pub start_url: String,
33− pub display: String,
34− pub background_color: Option<String>,
35− pub theme_color: Option<String>,
36− pub description: Option<String>,
37− pub icons: Vec<(String, String)>, // (src, sizes)
38−}
39−
4023#[derive(Debug, Clone)]
4124pub struct PreloadHint {
4225 pub href: String,
crates/htmlang-core/src/codegen.rs+3−160
@@ -876,88 +876,8 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
876876 None => String::new(),
877877 };
878878
879− // JSON-LD blocks
880− let json_ld_html: String = doc
881− .json_ld_blocks
882− .iter()
883− .map(|block| {
884− if dev {
885− format!(
886− "<script type=\"application/ld+json\">\n{}\n</script>\n",
887− block
888− )
889− } else {
890− format!("<script type=\"application/ld+json\">{}</script>", block)
891− }
892− })
893− .collect();
894−
895− // Manifest link
896− let manifest_html = if let Some(ref manifest) = doc.manifest {
897− let mut json = String::from("{");
898− json.push_str(&format!("\"name\":{},", json_str(&manifest.name)));
899− if let Some(ref short) = manifest.short_name {
900− json.push_str(&format!("\"short_name\":{},", json_str(short)));
901− }
902− json.push_str(&format!("\"start_url\":{},", json_str(&manifest.start_url)));
903− json.push_str(&format!("\"display\":{}", json_str(&manifest.display)));
904− if let Some(ref bg) = manifest.background_color {
905− json.push_str(&format!(",\"background_color\":{}", json_str(bg)));
906− }
907− if let Some(ref tc) = manifest.theme_color {
908− json.push_str(&format!(",\"theme_color\":{}", json_str(tc)));
909− }
910− if let Some(ref desc) = manifest.description {
911− json.push_str(&format!(",\"description\":{}", json_str(desc)));
912− }
913− if !manifest.icons.is_empty() {
914− json.push_str(",\"icons\":[");
915− for (i, (src, sizes)) in manifest.icons.iter().enumerate() {
916− if i > 0 {
917− json.push(',');
918− }
919− json.push_str(&format!(
920− "{{\"src\":{},\"sizes\":{},\"type\":\"image/png\"}}",
921− json_str(src),
922− json_str(sizes)
923− ));
924− }
925− json.push(']');
926− }
927− json.push('}');
928−
929− // Inline the manifest as a data URI
930− let encoded = base64_encode(json.as_bytes());
931− if dev {
932− format!(
933− "<link rel=\"manifest\" href=\"data:application/manifest+json;base64,{}\">\n",
934− encoded
935− )
936− } else {
937− format!(
938− "<link rel=\"manifest\" href=\"data:application/manifest+json;base64,{}\">",
939− encoded
940− )
941− }
942− } else {
943− String::new()
944− };
945−
946− // Preload hints (auto-detect fonts from @font-face)
879+ // Preload hints
947880 let mut preload_html = String::new();
948− for (_, url) in &doc.font_faces {
949− if dev {
950− preload_html.push_str(&format!(
951− "<link rel=\"preload\" href=\"{}\" as=\"font\" type=\"font/woff2\" crossorigin>\n",
952− url
953− ));
954− } else {
955− preload_html.push_str(&format!(
956− "<link rel=\"preload\" href=\"{}\" as=\"font\" type=\"font/woff2\" crossorigin>",
957− url
958− ));
959− }
960− }
961881 // Explicit preload hints from the document
962882 for hint in &doc.preload_hints {
963883 if dev {
@@ -977,26 +897,6 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
977897 }
978898 }
979899
980− // Auto theme-color meta from @theme primary token
981− let theme_color_html = doc
982− .theme_tokens
983− .iter()
984− .find(|(name, _)| name == "primary" || name == "theme-color")
985− .map(|(_, value)| {
986− if dev {
987− format!(
988− "<meta name=\"theme-color\" content=\"{}\">\n",
989− html_escape(value)
990− )
991− } else {
992− format!(
993− "<meta name=\"theme-color\" content=\"{}\">",
994− html_escape(value)
995− )
996− }
997− })
998− .unwrap_or_default();
999−
1000900 // Focus-visible CSS for interactive elements (accessibility)
1001901 let focus_visible_css = if ctx.has_interactive {
1002902 if dev {
@@ -1032,7 +932,7 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
1032932<meta charset=\"utf-8\">
1033933<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
1034934<title>{title}</title>
1035−{theme_color_html}{base_html}{canonical_html}{manifest_html}{preload_html}{dns_prefetch_html}{meta_html}{og_html}{favicon_html}{json_ld_html}{head_html}\
935+{base_html}{canonical_html}{preload_html}{dns_prefetch_html}{meta_html}{og_html}{favicon_html}{head_html}\
1036936<style>
1037937{reset_css}{element_css}\
1038938</style>
@@ -1044,15 +944,12 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
1044944",
1045945 title = html_escape(title),
1046946 lang_attr = lang_attr,
1047− theme_color_html = theme_color_html,
1048947 base_html = base_html,
1049948 canonical_html = canonical_html,
1050− manifest_html = manifest_html,
1051949 preload_html = preload_html,
1052950 dns_prefetch_html = dns_prefetch_html,
1053951 meta_html = meta_html,
1054952 favicon_html = favicon_html,
1055− json_ld_html = json_ld_html,
1056953 head_html = head_html,
1057954 og_html = og_html,
1058955 reset_css = reset_css,
@@ -1061,19 +958,16 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
1061958 )
1062959 } else {
1063960 format!(
1064− "<!DOCTYPE html><html{lang_attr}><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title>{theme_color_html}{base_html}{canonical_html}{manifest_html}{preload_html}{dns_prefetch_html}{meta_html}{og_html}{favicon_html}{json_ld_html}{head_html}<style>{reset_css}{element_css}</style></head><body>{body}</body></html>",
961+ "<!DOCTYPE html><html{lang_attr}><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title>{base_html}{canonical_html}{preload_html}{dns_prefetch_html}{meta_html}{og_html}{favicon_html}{head_html}<style>{reset_css}{element_css}</style></head><body>{body}</body></html>",
1065962 title = html_escape(title),
1066963 lang_attr = lang_attr,
1067− theme_color_html = theme_color_html,
1068964 base_html = base_html,
1069965 canonical_html = canonical_html,
1070− manifest_html = manifest_html,
1071966 preload_html = preload_html,
1072967 dns_prefetch_html = dns_prefetch_html,
1073968 meta_html = meta_html,
1074969 og_html = og_html,
1075970 favicon_html = favicon_html,
1076− json_ld_html = json_ld_html,
1077971 head_html = head_html,
1078972 reset_css = reset_css,
1079973 element_css = element_css,
@@ -1093,57 +987,12 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
1093987 }
1094988}
1095989
1096−/// Encode `s` as a JSON string literal (with surrounding quotes).
1097−pub(crate) fn json_str(s: &str) -> String {
1098− let mut out = String::with_capacity(s.len() + 2);
1099− out.push('"');
1100− for c in s.chars() {
1101− match c {
1102− '"' => out.push_str("\\\""),
1103− '\\' => out.push_str("\\\\"),
1104− '\n' => out.push_str("\\n"),
1105− '\r' => out.push_str("\\r"),
1106− '\t' => out.push_str("\\t"),
1107− c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
1108− c => out.push(c),
1109− }
1110− }
1111− out.push('"');
1112− out
1113−}
1114−
1115990/// Assemble every CSS block the document needs (font faces, custom
1116991/// properties, generated class rules, keyframes, and user CSS). Shared by
1117992/// full-page and partial output so both emit the same styles.
1118993fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> String {
1119994 let mut element_css = String::new();
1120995
1121− // @font-face rules, in declaration order
1122− for (name, url) in &doc.font_faces {
1123− let format_hint = if url.ends_with(".woff2") {
1124− " format('woff2')"
1125− } else if url.ends_with(".woff") {
1126− " format('woff')"
1127− } else if url.ends_with(".ttf") {
1128− " format('truetype')"
1129− } else if url.ends_with(".otf") {
1130− " format('opentype')"
1131− } else {
1132− ""
1133− };
1134− if dev {
1135− element_css.push_str(&format!(
1136− "@font-face {{\n font-family: '{}';\n src: url('{}'){};\n font-display: swap;\n}}\n",
1137− name, url, format_hint
1138− ));
1139− } else {
1140− element_css.push_str(&format!(
1141− "@font-face{{font-family:'{}';src:url('{}'){};font-display:swap}}",
1142− name, url, format_hint
1143− ));
1144− }
1145− }
1146−
1147996 // Collect all CSS custom properties (explicit `@let --name` / `@theme`
1148997 // tokens, plus any auto-extracted repeats) so they can be emitted in a
1149998 // single `:root` block below.
@@ -1151,12 +1000,6 @@ fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> Stri
11511000 for (name, value) in &doc.css_vars {
11521001 root_vars.push((name.clone(), value.clone()));
11531002 }
1154− for (name, value) in &doc.theme_tokens {
1155− let css_name = format!("--{}", name);
1156− if !root_vars.iter().any(|(n, _)| *n == css_name) {
1157− root_vars.push((css_name, value.clone()));
1158− }
1159− }
11601003
11611004 // Generated rules always go in `@layer htmlang`, so unlayered user CSS
11621005 // (`@style`, `@raw`) overrides them regardless of specificity.
crates/htmlang-core/src/codegen_tests.rs+1−7
@@ -1,6 +1,6 @@
11#[cfg(test)]
22mod tests {
3− use crate::codegen::{generate, generate_dev, generate_partial, json_str, short_class_name};
3+ use crate::codegen::{generate, generate_dev, generate_partial, short_class_name};
44 use crate::parser::parse;
55
66 fn compile(src: &str) -> String {
@@ -93,10 +93,4 @@ mod tests {
9393 "duplicate CSS rule emitted ({count} times) in:\n{out}"
9494 );
9595 }
96−
97− #[test]
98− fn json_str_escapes_quotes_and_controls() {
99− assert_eq!(json_str(r#"My "App""#), r#""My \"App\"""#);
100− assert_eq!(json_str("a\\b\n\t\u{1}"), r#""a\\b\n\t\u0001""#);
101− }
10296}
crates/htmlang-core/src/parser.rs+15−274
@@ -85,7 +85,6 @@ struct ParseContext {
8585 css_vars: Vec<(String, String)>,
8686 custom_css: Vec<String>,
8787 og_tags: Vec<(String, String)>,
88− custom_breakpoints: Vec<(String, String)>,
8988 diagnostics: Vec<Diagnostic>,
9089 base_path: Option<PathBuf>,
9190 included_files: Vec<PathBuf>,
@@ -104,20 +103,10 @@ struct ParseContext {
104103 fn_lines: HashMap<String, usize>,
105104 /// Line numbers of attribute bundle definitions (name -> line)
106105 define_lines: HashMap<String, usize>,
107− /// Deprecated functions: name -> deprecation message
108− deprecated_fns: HashMap<String, String>,
109− /// Theme tokens: (name, value) pairs from @theme
110− theme_tokens: Vec<(String, String)>,
111106 /// Canonical URL
112107 canonical: Option<String>,
113108 /// Base URL for relative links
114109 base_url: Option<String>,
115− /// @font-face declarations: (font_name, url)
116− font_faces: Vec<(String, String)>,
117− /// @json-ld blocks
118− json_ld_blocks: Vec<String>,
119− /// @manifest configuration
120− manifest: Option<crate::ast::ManifestConfig>,
121110 /// Track @import paths for circular dependency detection
122111 import_stack: Vec<PathBuf>,
123112}
@@ -197,7 +186,6 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
197186 css_vars: Vec::new(),
198187 custom_css: Vec::new(),
199188 og_tags: Vec::new(),
200− custom_breakpoints: Vec::new(),
201189 diagnostics: Vec::new(),
202190 base_path: base_path.map(|p| p.to_path_buf()),
203191 included_files: Vec::new(),
@@ -210,13 +198,8 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
210198 let_lines: HashMap::new(),
211199 fn_lines: HashMap::new(),
212200 define_lines: HashMap::new(),
213− deprecated_fns: HashMap::new(),
214− theme_tokens: Vec::new(),
215201 canonical: None,
216202 base_url: None,
217− font_faces: Vec::new(),
218− json_ld_blocks: Vec::new(),
219− manifest: None,
220203 import_stack: Vec::new(),
221204 };
222205 load_prelude(&mut ctx);
@@ -236,13 +219,8 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
236219 css_vars: ctx.css_vars,
237220 custom_css: ctx.custom_css,
238221 og_tags: ctx.og_tags,
239− custom_breakpoints: ctx.custom_breakpoints,
240− theme_tokens: ctx.theme_tokens,
241222 canonical: ctx.canonical,
242223 base_url: ctx.base_url,
243− font_faces: ctx.font_faces,
244− json_ld_blocks: ctx.json_ld_blocks,
245− manifest: ctx.manifest,
246224 preload_hints: collect_image_preload_hints(&nodes),
247225 nodes,
248226 },
@@ -744,17 +722,6 @@ impl Parser {
744722 }
745723
746724
747− if let Some(rest) = content.strip_prefix("@breakpoint ") {
748− let rest = rest.trim();
749− if let Some((name, value)) = rest.split_once(' ') {
750− ctx.custom_breakpoints.push((
751− name.trim().to_string(),
752− substitute_vars(value.trim(), &ctx.variables),
753− ));
754− }
755− return Ok(None);
756− }
757−
758725 if content == "@head" || content.starts_with("@head ") {
759726 // Collect indented body lines as raw head content
760727 let mut head_content = String::new();
@@ -851,61 +818,6 @@ impl Parser {
851818 }
852819 }
853820
854− // --- @manifest (PWA web manifest) ---
855− if content.trim() == "@manifest" || content.starts_with("@manifest ") {
856− let mut name = content
857− .strip_prefix("@manifest")
858− .unwrap_or("")
859− .trim()
860− .to_string();
861− if name.is_empty() {
862− name = ctx.page_title.clone().unwrap_or_else(|| "App".to_string());
863− }
864− let name = substitute_vars(&name, &ctx.variables);
865− let mut manifest = crate::ast::ManifestConfig {
866− name: name.clone(),
867− short_name: None,
868− start_url: "/".to_string(),
869− display: "standalone".to_string(),
870− background_color: None,
871− theme_color: None,
872− description: None,
873− icons: Vec::new(),
874− };
875− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
876− if let LineContent::Normal(ref s) = self.lines[self.pos].content {
877− let trimmed = s.trim();
878− if let Some((key, value)) = trimmed.split_once(' ') {
879− let value = substitute_vars(value.trim(), &ctx.variables);
880− match key.trim() {
881− "short_name" | "short-name" => manifest.short_name = Some(value),
882− "start_url" | "start-url" => manifest.start_url = value,
883− "display" => manifest.display = value,
884− "background_color" | "background-color" => {
885− manifest.background_color = Some(value)
886− }
887− "theme_color" | "theme-color" => manifest.theme_color = Some(value),
888− "description" => manifest.description = Some(value),
889− "icon" => {
890− // icon src sizes (e.g., icon /icon-192.png 192x192)
891− let parts: Vec<&str> = value.splitn(2, ' ').collect();
892− if parts.len() == 2 {
893− manifest
894− .icons
895− .push((parts[0].to_string(), parts[1].to_string()));
896− } else {
897− manifest.icons.push((value, "192x192".to_string()));
898− }
899− }
900− _ => {}
901− }
902− }
903− }
904− self.pos += 1;
905− }
906− ctx.manifest = Some(manifest);
907− return Ok(None);
908− }
909821
910822
911823 // --- @assert directive (compile-time assertions) ---
@@ -1662,94 +1574,6 @@ impl Parser {
16621574 }
16631575
16641576
1665− // --- @match ---
1666−
1667− if let Some(rest) = content.strip_prefix("@match ") {
1668− let match_val = substitute_vars(rest.trim(), &ctx.variables);
1669− track_var_refs(rest.trim(), &mut ctx.used_variables);
1670−
1671− // Collect all child lines
1672− let mut match_lines = Vec::new();
1673− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1674− match_lines.push(self.lines[self.pos].clone());
1675− self.pos += 1;
1676− }
1677−
1678− if match_lines.is_empty() {
1679− return Ok(None);
1680− }
1681−
1682− let case_indent = match_lines[0].indent;
1683−
1684− // Group into cases: (Some(value), body) or (None, body) for @default
1685− let mut cases: Vec<(Option<String>, Vec<Line>)> = Vec::new();
1686− let mut mi = 0;
1687− while mi < match_lines.len() {
1688− if match_lines[mi].indent == case_indent {
1689− if let LineContent::Normal(ref s) = match_lines[mi].content {
1690− let trimmed = s.trim();
1691− if let Some(case_val) = trimmed.strip_prefix("@case ") {
1692− let case_val = substitute_vars(case_val.trim(), &ctx.variables);
1693− mi += 1;
1694− let mut body = Vec::new();
1695− while mi < match_lines.len() && match_lines[mi].indent > case_indent {
1696− body.push(match_lines[mi].clone());
1697− mi += 1;
1698− }
1699− cases.push((Some(case_val), body));
1700− } else if trimmed == "@default" {
1701− mi += 1;
1702− let mut body = Vec::new();
1703− while mi < match_lines.len() && match_lines[mi].indent > case_indent {
1704− body.push(match_lines[mi].clone());
1705− mi += 1;
1706− }
1707− cases.push((None, body));
1708− } else {
1709− mi += 1;
1710− }
1711− } else {
1712− mi += 1;
1713− }
1714− } else {
1715− mi += 1;
1716− }
1717− }
1718−
1719− // Find first matching case or @default
1720− let body_lines = cases
1721− .into_iter()
1722− .find(|(case_val, _)| match case_val {
1723− Some(v) => *v == match_val,
1724− None => true,
1725− })
1726− .map(|(_, lines)| lines)
1727− .unwrap_or_default();
1728−
1729− if body_lines.is_empty() {
1730− return Ok(None);
1731− }
1732−
1733− let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
1734− let adjusted: Vec<Line> = body_lines
1735− .iter()
1736− .map(|l| Line {
1737− indent: l.indent - min_indent,
1738− content: l.content.clone(),
1739− line_num: l.line_num,
1740− })
1741− .collect();
1742−
1743− let saved_vars = ctx.variables.clone();
1744− let mut body_parser = Parser {
1745− lines: adjusted,
1746− pos: 0,
1747− };
1748− let nodes = body_parser.parse_children(0, ctx);
1749− ctx.variables = saved_vars;
1750− return Ok(Some(nodes));
1751− }
1752−
17531577
17541578 // --- @warn / @debug ---
17551579
@@ -1794,85 +1618,6 @@ impl Parser {
17941618 return Ok(None);
17951619 }
17961620
1797− // --- @theme directive ---
1798−
1799− if content.trim() == "@theme" {
1800− let mut tokens = Vec::new();
1801− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1802− if let LineContent::Normal(ref s) = self.lines[self.pos].content {
1803− let trimmed = s.trim();
1804− if let Some((name, value)) = trimmed.split_once(' ') {
1805− let name = name.trim().to_string();
1806− let value = value.trim().to_string();
1807− tokens.push((name.clone(), value.clone()));
1808− // Set as regular variable
1809− ctx.variables.insert(name.clone(), value.clone());
1810− // Also set as CSS custom property
1811− let css_name = format!("--{}", name);
1812− ctx.css_vars.push((css_name, value));
1813− }
1814− }
1815− self.pos += 1;
1816− }
1817− ctx.theme_tokens = tokens;
1818− return Ok(None);
1819− }
1820−
1821−
1822− // --- @font-face directive ---
1823−
1824− if let Some(rest) = content.strip_prefix("@font-face ") {
1825− let rest = rest.trim();
1826− if let Some((name, url)) = rest.split_once(' ') {
1827− ctx.font_faces.push((
1828− substitute_vars(name.trim(), &ctx.variables),
1829− substitute_vars(url.trim(), &ctx.variables),
1830− ));
1831− }
1832− return Ok(None);
1833− }
1834−
1835− // --- @json-ld block ---
1836−
1837− if content.trim() == "@json-ld" {
1838− let mut block = String::new();
1839− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1840− match &self.lines[self.pos].content {
1841− LineContent::Normal(s) => {
1842− block.push_str(s.trim());
1843− block.push('\n');
1844− }
1845− LineContent::Raw(s) => {
1846− block.push_str(s);
1847− block.push('\n');
1848− }
1849− }
1850− self.pos += 1;
1851− }
1852− let trimmed = block.trim().to_string();
1853− if !trimmed.is_empty() {
1854− ctx.json_ld_blocks.push(trimmed);
1855− }
1856− return Ok(None);
1857− }
1858−
1859−
1860− // --- @deprecated annotation ---
1861−
1862− if let Some(rest) = content.strip_prefix("@deprecated ") {
1863− let message = rest.trim().to_string();
1864− // Peek at the next line to get the function name
1865− if self.pos < self.lines.len()
1866− && let LineContent::Normal(ref s) = self.lines[self.pos].content
1867− && let Some(fn_rest) = s.trim().strip_prefix("@let ")
1868− {
1869− let parts: Vec<&str> = fn_rest.split_whitespace().collect();
1870− if let Some(&fn_name) = parts.first() {
1871− ctx.deprecated_fns.insert(fn_name.to_string(), message);
1872− }
1873− }
1874− return Ok(None);
1875− }
18761621
18771622 // --- @extends (template inheritance) ---
18781623
@@ -2014,16 +1759,6 @@ impl Parser {
20141759 let name = extract_element_name(&content);
20151760 if ctx.functions.contains_key(name) {
20161761 ctx.used_functions.insert(name.to_string());
2017− // Emit deprecation warning if function is marked @deprecated
2018− if let Some(msg) = ctx.deprecated_fns.get(name) {
2019− ctx.diagnostics.push(Diagnostic {
2020− line: line_num,
2021− column: None,
2022− message: format!("@{} is deprecated: {}", name, msg),
2023− severity: Severity::Warning,
2024− source_line: Some(content.clone()),
2025− });
2026− }
20271762 let nodes = self.expand_fn_call(name, &content, current_indent, line_num, ctx)?;
20281763 return Ok(Some(nodes));
20291764 }
@@ -2529,19 +2264,10 @@ const KNOWN_DIRECTIVES: &[&str] = &[
25292264 "meta",
25302265 "head",
25312266 "style",
2532− "match",
2533− "case",
2534− "default",
25352267 "warn",
2536− "breakpoint",
2537− "theme",
2538− "deprecated",
25392268 "extends",
2540− "font-face",
2541− "json-ld",
25422269 "assert",
25432270 "markdown",
2544− "manifest",
25452271 "data",
25462272 "svg",
25472273];
@@ -2604,6 +2330,21 @@ const REMOVED_SYNTAX: &[(&str, &str)] = &[
26042330 ("@base", "use `@page [base ...] Title`"),
26052331 ("@og", "use `@meta og:NAME VALUE`"),
26062332 ("@debug", "use `@warn`"),
2333+ ("@match", "use `@if $x == a` / `@else if $x == b` / `@else`"),
2334+ ("@case", "use `@if $x == a` / `@else if $x == b` / `@else`"),
2335+ ("@default", "use `@else` in an `@if` chain"),
2336+ ("@theme", "use a `@let --name value` line per token"),
2337+ (
2338+ "@json-ld",
2339+ "put a `<script type=\"application/ld+json\">` in `@head`",
2340+ ),
2341+ ("@font-face", "write the `@font-face` rule in an `@style` block"),
2342+ (
2343+ "@manifest",
2344+ "write a manifest.json file and link it from `@head`",
2345+ ),
2346+ ("@breakpoint", "write the media query in an `@style` block"),
2347+ ("@deprecated", "remove it"),
26072348 (
26082349 "@collection",
26092350 "use `@data $name dir/*.json` (each file becomes `$name.STEM.key`)",
examples/demo.hl+17−14
@@ -5,11 +5,15 @@
55@meta og:type website
66
77-- Design tokens become both $vars and CSS custom properties
8−@theme
9− primary #3b82f6
10− ink #0f172a
11− muted #64748b
12− subtle #f8fafc
8+@let primary #3b82f6
9+@let --primary #3b82f6
10+@meta theme-color #3b82f6
11+@let ink #0f172a
12+@let --ink #0f172a
13+@let muted #64748b
14+@let --muted #64748b
15+@let subtle #f8fafc
16+@let --subtle #f8fafc
1317
1418-- Computed @let with arithmetic
1519@let base 8
@@ -172,15 +176,14 @@
172176
173177 -- @match
174178 @let status ready
175− @match $status
176− @case pending
177− @text [color #f59e0b] Pending…
178− @case ready
179− @text [color #10b981, bold] ✓ Ready
180− @case error
181− @text [color #ef4444] Error
182− @default
183− @text Unknown
179+ @if $status == "pending"
180+ @text [color #f59e0b] Pending…
181+ @else if $status == "ready"
182+ @text [color #10b981, bold] ✓ Ready
183+ @else if $status == "error"
184+ @text [color #ef4444] Error
185+ @else
186+ @text Unknown
184187
185188 -- Conditional attribute value + before: pseudo-element
186189 @let emphasized true
src/upgrade.rs+135−1
@@ -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", "@json-ld"];
48+const VERBATIM_BODIES: &[&str] = &["@style", "@script", "@markdown", "@head"];
4949
5050pub fn upgrade(input: &str) -> Upgrade {
5151 let mut manual = Vec::new();
@@ -297,6 +297,90 @@ fn rewrite_block(
297297 return Some(Block { lines: out, end });
298298 }
299299
300+ // @match $v / @case a / @default → @if $v == "a" / @else if ... / @else
301+ if let Some(value) = trimmed.strip_prefix("@match ") {
302+ let value = value.trim();
303+ let case_indent = body.iter().find(|l| !l.trim().is_empty()).map(|l| indent_of(l));
304+ let mut out = Vec::new();
305+ let mut first = true;
306+ let mut j = 0;
307+ while j < body.len() {
308+ let l = body[j];
309+ let t = l.trim();
310+ if Some(indent_of(l)) == case_indent {
311+ let head = if let Some(case) = t.strip_prefix("@case ") {
312+ let case = case.trim();
313+ let quoted = if case.starts_with('"') {
314+ case.to_string()
315+ } else {
316+ format!("\"{}\"", case)
317+ };
318+ let keyword = if first { "@if" } else { "@else if" };
319+ first = false;
320+ format!("{pad}{} {} == {}", keyword, value, quoted)
321+ } else if t == "@default" {
322+ format!("{pad}@else")
323+ } else {
324+ j += 1;
325+ continue;
326+ };
327+ out.push(head);
328+ // The case body moves up one level, under the @if.
329+ let body_end = (j + 1..body.len())
330+ .find(|&k| !body[k].trim().is_empty() && Some(indent_of(body[k])) <= case_indent)
331+ .unwrap_or(body.len());
332+ out.extend(dedent_block(&body[j + 1..body_end], indent + 2));
333+ j = body_end;
334+ } else {
335+ j += 1;
336+ }
337+ }
338+ return Some(Block { lines: out, end });
339+ }
340+
341+ // @theme / `name value` lines → `@let name value` and `@let --name value`
342+ if trimmed == "@theme" {
343+ let mut out = Vec::new();
344+ for l in body {
345+ if let Some((name, value)) = l.trim().split_once(' ') {
346+ let value = value.trim();
347+ out.push(format!("{pad}@let {} {}", name, value));
348+ out.push(format!("{pad}@let --{} {}", name, value));
349+ if name == "primary" || name == "theme-color" {
350+ out.push(format!("{pad}@meta theme-color {}", value));
351+ }
352+ }
353+ }
354+ return Some(Block { lines: out, end });
355+ }
356+
357+ // @json-ld with an indented JSON body → a script in @head
358+ if trimmed == "@json-ld" {
359+ let mut out = vec![
360+ format!("{pad}@head"),
361+ format!("{pad} <script type=\"application/ld+json\">"),
362+ ];
363+ out.extend(dedent_block(body, indent).into_iter().map(|l| {
364+ if l.is_empty() {
365+ l
366+ } else {
367+ format!("{pad} {}", l.trim_start())
368+ }
369+ }));
370+ out.push(format!("{pad} </script>"));
371+ return Some(Block { lines: out, end });
372+ }
373+
374+ if trimmed.starts_with("@manifest") {
375+ manual.push((
376+ i + 1,
377+ "@manifest was removed: write a manifest.json file and add \
378+ `<link rel=\"manifest\" href=\"manifest.json\">` to @head"
379+ .to_string(),
380+ ));
381+ return None;
382+ }
383+
300384 // @defer → its body, dedented
301385 if trimmed == "@defer" || trimmed.starts_with("@defer ") {
302386 return Some(Block {
@@ -541,6 +625,34 @@ fn rewrite_directive_line(
541625 ));
542626 return line.to_string();
543627 }
628+ if let Some(rest) = trimmed.strip_prefix("@font-face ")
629+ && let Some((name, url)) = rest.trim().split_once(' ')
630+ {
631+ let url = url.trim();
632+ let format = match url.rsplit('.').next() {
633+ Some("woff2") => " format('woff2')",
634+ Some("woff") => " format('woff')",
635+ Some("ttf") => " format('truetype')",
636+ Some("otf") => " format('opentype')",
637+ _ => "",
638+ };
639+ return format!(
640+ "{pad}@style\n{pad} @font-face {{ font-family: '{name}'; src: url('{url}'){format}; font-display: swap; }}\n\
641+ {pad}@head\n{pad} <link rel=\"preload\" href=\"{url}\" as=\"font\" crossorigin>"
642+ );
643+ }
644+ if trimmed.starts_with("@deprecated ") {
645+ return String::new();
646+ }
647+ if trimmed.starts_with("@breakpoint ") {
648+ manual.push((
649+ idx + 1,
650+ "@breakpoint was removed (its prefixes never took effect): write the media \
651+ query in an @style block"
652+ .to_string(),
653+ ));
654+ return line.to_string();
655+ }
544656 if let Some(rest) = trimmed.strip_prefix("@import ") {
545657 return format!("{pad}@include {}", rest);
546658 }
@@ -1134,6 +1246,28 @@ mod tests {
11341246 assert_eq!(up("@el [critical, padding 4]"), "@el [padding 4]");
11351247 }
11361248
1249+ #[test]
1250+ fn small_directives() {
1251+ assert_eq!(
1252+ up("@match $v\n @case a\n @text A\n @case b c\n @text B\n @default\n @text D"),
1253+ "@if $v == \"a\"\n @text A\n@else if $v == \"b c\"\n @text B\n@else\n @text D"
1254+ );
1255+ assert_eq!(
1256+ up("@theme\n primary #3b82f6\n radius 8"),
1257+ "@let primary #3b82f6\n@let --primary #3b82f6\n@meta theme-color #3b82f6\n@let radius 8\n@let --radius 8"
1258+ );
1259+ assert_eq!(
1260+ up("@json-ld\n {\"a\": 1}"),
1261+ "@head\n <script type=\"application/ld+json\">\n {\"a\": 1}\n </script>"
1262+ );
1263+ assert_eq!(
1264+ up("@font-face Inter fonts/inter.woff2"),
1265+ "@style\n @font-face { font-family: 'Inter'; src: url('fonts/inter.woff2') format('woff2'); font-display: swap; }\n@head\n <link rel=\"preload\" href=\"fonts/inter.woff2\" as=\"font\" crossorigin>"
1266+ );
1267+ assert_eq!(up("@deprecated old\n@let x 1"), "\n@let x 1");
1268+ assert_eq!(upgrade("@breakpoint tablet 600").manual.len(), 1);
1269+ }
1270+
11371271 #[test]
11381272 fn verbatim_regions_are_untouched() {
11391273 let src = "@script\n for (x of y) {}\n@raw \"\"\"\n@unless\n\"\"\"\n@style\n .p { }";
tests/snapshots.rs+18−36
@@ -840,7 +840,7 @@ fn video_with_multiple_attrs() {
840840#[test]
841841fn match_selects_correct_case() {
842842 let output = compile(
843− "@let x b\n@match $x\n @case a\n @text A\n @case b\n @text B\n @default\n @text D",
843+ "@let x b\n@if $x == \"a\"\n @text A\n@else if $x == \"b\"\n @text B\n@else\n @text D",
844844 );
845845 assert!(output.contains("B"));
846846 assert!(!output.contains(">A<"));
@@ -850,14 +850,14 @@ fn match_selects_correct_case() {
850850#[test]
851851fn match_falls_to_default() {
852852 let output =
853− compile("@let x z\n@match $x\n @case a\n @text A\n @default\n @text Default");
853+ compile("@let x z\n@if $x == \"a\"\n @text A\n@else\n @text Default");
854854 assert!(output.contains("Default"));
855855 assert!(!output.contains(">A<"));
856856}
857857
858858#[test]
859859fn match_no_match_no_default() {
860− let output = compile("@let x z\n@match $x\n @case a\n @text A\n @case b\n @text B");
860+ let output = compile("@let x z\n@if $x == \"a\"\n @text A\n@else if $x == \"b\"\n @text B");
861861 assert!(!output.contains(">A<"));
862862 assert!(!output.contains(">B<"));
863863}
@@ -1096,7 +1096,7 @@ fn dns_prefetch_for_external_domains() {
10961096
10971097#[test]
10981098fn theme_color_meta_from_theme() {
1099− let output = compile("@page T\n@theme\n primary #3b82f6\n@el\n test");
1099+ let output = compile("@page T\n@let primary #3b82f6\n@let --primary #3b82f6\n@meta theme-color #3b82f6\n@el\n test");
11001100 assert!(output.contains("theme-color"));
11011101 assert!(output.contains("#3b82f6"));
11021102}
@@ -3194,7 +3194,7 @@ fn test_keyframes_percentage() {
31943194#[test]
31953195fn test_theme_directive() {
31963196 let result = htmlang::parser::parse(
3197− "@theme\n primary #3b82f6\n spacing-md 16\n\n@el [background $primary, padding $spacing-md] Content",
3197+ "@let primary #3b82f6\n@let --primary #3b82f6\n@meta theme-color #3b82f6\n@let spacing-md 16\n@let --spacing-md 16\n\n@el [background $primary, padding $spacing-md] Content",
31983198 );
31993199 let diags = &result.diagnostics;
32003200 let errors: Vec<_> = diags
@@ -3227,24 +3227,9 @@ fn test_theme_directive() {
32273227}
32283228
32293229#[test]
3230−fn test_deprecated_fn() {
3231− let result = htmlang::parser::parse(
3232− "@deprecated Use @new-card instead\n@let old-card $title\n @text $title\n\n@old-card [title Hello]",
3233− );
3234− let warnings: Vec<_> = result
3235− .diagnostics
3236− .iter()
3237− .filter(|d| d.message.contains("deprecated"))
3238− .collect();
3239− assert!(
3240− !warnings.is_empty(),
3241− "calling deprecated fn should warn, got: {:?}",
3242− result.diagnostics
3243− );
3244− assert!(
3245− warnings[0].message.contains("Use @new-card instead"),
3246− "deprecation message should be included"
3247− );
3230+fn deprecated_was_removed() {
3231+ let diags = parse_diagnostics("@deprecated old\n@let card\n @text x");
3232+ assert!(diags.iter().any(|d| d.message.contains("`@deprecated` was removed")), "{:?}", diags);
32483233}
32493234
32503235#[test]
@@ -3653,10 +3638,10 @@ fn base_directive() {
36533638
36543639#[test]
36553640fn font_face_directive() {
3656− let output = compile("@page T\n@font-face Inter fonts/inter.woff2\n@text Hello");
3641+ let output = compile("@page T\n@style\n @font-face { font-family: 'Inter'; src: url('fonts/inter.woff2') format('woff2'); font-display: swap; }\n@head\n <link rel=\"preload\" href=\"fonts/inter.woff2\" as=\"font\" crossorigin>\n@text Hello");
36573642 assert!(output.contains("@font-face"), "font-face: {}", output);
36583643 assert!(
3659− output.contains("font-family:'Inter'"),
3644+ output.contains("font-family: 'Inter'"),
36603645 "font name: {}",
36613646 output
36623647 );
@@ -3666,7 +3651,7 @@ fn font_face_directive() {
36663651
36673652#[test]
36683653fn json_ld_directive() {
3669− let output = compile("@page T\n@json-ld\n {\"@type\": \"WebPage\"}\n@text Hello");
3654+ let output = compile("@page T\n@head\n <script type=\"application/ld+json\">\n {\"@type\": \"WebPage\"}\n </script>\n@text Hello");
36703655 assert!(
36713656 output.contains("application/ld+json"),
36723657 "json-ld type: {}",
@@ -4049,7 +4034,7 @@ fn function_without_style_has_no_wrapper() {
40494034#[test]
40504035fn switch_matches_case() {
40514036 let html = compile(
4052− "@let variant primary\n@match $variant\n @case primary\n @text Primary\n @case danger\n @text Danger\n",
4037+ "@let variant primary\n@if $variant == \"primary\"\n @text Primary\n@else if $variant == \"danger\"\n @text Danger\n",
40534038 );
40544039 assert!(html.contains("Primary"));
40554040 assert!(!html.contains("Danger"));
@@ -4058,7 +4043,7 @@ fn switch_matches_case() {
40584043#[test]
40594044fn switch_falls_to_default() {
40604045 let html = compile(
4061− "@let variant unknown\n@match $variant\n @case primary\n @text Primary\n @default\n @text Default\n",
4046+ "@let variant unknown\n@if $variant == \"primary\"\n @text Primary\n@else\n @text Default\n",
40624047 );
40634048 assert!(!html.contains("Primary"));
40644049 assert!(html.contains("Default"));
@@ -4067,11 +4052,11 @@ fn switch_falls_to_default() {
40674052#[test]
40684053fn switch_with_attrs() {
40694054 let _html = compile(
4070− "@let variant primary\n@match $variant\n @case primary\n @let __switch [background blue, color white]\n @case danger\n @let __switch [background red, color white]\n",
4055+ "@let variant primary\n@if $variant == \"primary\"\n @let __switch [background blue, color white]\n@else if $variant == \"danger\"\n @let __switch [background red, color white]\n",
40714056 );
40724057 // The @switch should register matched attrs as __switch define
40734058 let result = htmlang::parser::parse(
4074− "@let variant primary\n@match $variant\n @case primary\n @let __switch [background blue, color white]\n @case danger\n @let __switch [background red, color white]\n",
4059+ "@let variant primary\n@if $variant == \"primary\"\n @let __switch [background blue, color white]\n@else if $variant == \"danger\"\n @let __switch [background red, color white]\n",
40754060 );
40764061 assert!(result.document.defines.contains_key("__switch"));
40774062}
@@ -4611,12 +4596,9 @@ fn starting_style_generates_css() {
46114596}
46124597
46134598#[test]
4614−fn manifest_generates_link() {
4615− let output = compile("@page App\n@manifest My App\n display standalone\n@text hi");
4616− assert!(
4617− output.contains("rel=\"manifest\""),
4618− "should generate manifest link"
4619− );
4599+fn manifest_was_removed() {
4600+ let diags = parse_diagnostics("@page App\n@manifest My App\n display standalone\n@text hi");
4601+ assert!(diags.iter().any(|d| d.message.contains("`@manifest` was removed")), "{:?}", diags);
46204602}
46214603
46224604#[test]
tests/snapshots/manifest_directive.hl+2−7
@@ -1,12 +1,7 @@
11@page My App
22
3−@manifest My App
4− short-name App
5− start-url /
6− display standalone
7− theme-color #3b82f6
8− background-color #ffffff
9− description A sample PWA
3+@head
4+ <link rel="manifest" href="manifest.json">
105
116@column [padding 20]
127 @text Hello
tests/snapshots/manifest_directive.html+1−1
@@ -1 +1 @@
1−<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>My App</title><link rel="manifest" href="data:application/manifest+json;base64,eyJuYW1lIjoiTXkgQXBwIiwic2hvcnRfbmFtZSI6IkFwcCIsInN0YXJ0X3VybCI6Ii8iLCJkaXNwbGF5Ijoic3RhbmRhbG9uZSIsImJhY2tncm91bmRfY29sb3IiOiIjZmZmZmZmIiwidGhlbWVfY29sb3IiOiIjM2I4MmY2IiwiZGVzY3JpcHRpb24iOiJBIHNhbXBsZSBQV0EifQ=="><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;padding:20px;}}</style></head><body><div class="a"><span>Hello</span></div></body></html>
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>My App</title><link rel="manifest" href="manifest.json"><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;padding:20px;}}</style></head><body><div class="a"><span>Hello</span></div></body></html>
tests/snapshots/match_directive.hl+9−10
@@ -2,13 +2,12 @@
22
33@let theme dark
44
5−@match $theme
6− @case light
7− @el [background white, color black]
8− @text Light theme
9− @case dark
10− @el [background #333, color white]
11− @text Dark theme
12− @default
13− @el [background gray]
14− @text Default theme
5+@if $theme == "light"
6+ @el [background white, color black]
7+ @text Light theme
8+@else if $theme == "dark"
9+ @el [background #333, color white]
10+ @text Dark theme
11+@else
12+ @el [background gray]
13+ @text Default theme
tests/snapshots/new_directives.hl+13−5
@@ -1,12 +1,20 @@
11@page [canonical https://example.com/page, base https://example.com/] My Site
2−@font-face Inter fonts/inter.woff2
3−@font-face Mono fonts/mono.ttf
4−@json-ld
5− {
2+@style
3+ @font-face { font-family: 'Inter'; src: url('fonts/inter.woff2') format('woff2'); font-display: swap; }
4+@head
5+ <link rel="preload" href="fonts/inter.woff2" as="font" crossorigin>
6+@style
7+ @font-face { font-family: 'Mono'; src: url('fonts/mono.ttf') format('truetype'); font-display: swap; }
8+@head
9+ <link rel="preload" href="fonts/mono.ttf" as="font" crossorigin>
10+@head
11+ <script type="application/ld+json">
12+ {
613 "@context": "https://schema.org",
714 "@type": "WebPage",
815 "name": "My Site"
9− }
16+ }
17+ </script>
1018
1119@column [spacing 10]
1220 @text [font Inter] Hello with custom fonts
tests/snapshots/new_directives.html+4−2
@@ -1,5 +1,7 @@
1−<!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" type="font/woff2" crossorigin><link rel="preload" href="fonts/mono.ttf" as="font" type="font/woff2" crossorigin><script type="application/ld+json">{
1+<!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+{
23"@context": "https://schema.org",
34"@type": "WebPage",
45"name": "My Site"
5−}</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}}@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}@layer htmlang{.a{display:flex;flex-direction:column;gap:10px;}.b{font-family:Inter;}}</style></head><body><div class="a"><span class="b">Hello with custom fonts</span></div></body></html>
6+}
7+</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>