on master help sign in
Add 21 improvements: 5 new elements, 4 directives, 5 pseudo-states, 3 CSS properties, 2 CLI commands (clean/outline), LSP enhancements, VS Code snippets, and 25 tests
commit39f698de79264677c25f7db142fde9a870529559 authoriko <mail@iko.soy> dateWed 15 Apr 2026, 16:40 (6 months ago) parent0e17ca8
17 files changed+897−8
DESIGN.md+160 editors/vscode/snippets/htmlang.json+93 editors/vscode/syntaxes/htmlang.tmLanguage.json+2−2 src/ast.rs+10 src/bin/htmlang-lsp.rs+45−1 src/codegen.rs+177−3 src/convert.rs+3 src/fmt.rs+7−2 src/main.rs+94 src/parser.rs+89 tests/snapshots.rs+169 tests/snapshots/breadcrumb.hl+4 tests/snapshots/new_css_properties_4.hl+5 tests/snapshots/new_directives.hl+14 tests/snapshots/new_elements_6.hl+10 tests/snapshots/new_pseudos.hl+8 tests/snapshots/script_element.hl+7
DESIGN.md+160
@@ -54,6 +54,12 @@ A minimalist layout language inspired by elm-ui that compiles to static HTML.
5454| `@output` | output | Form calculation result |
5555| `@canvas` | canvas | Drawing surface for scripts |
5656
57+| `@script` | script | JavaScript (inline or external) |
58+| `@noscript` | noscript | Fallback when JS is disabled |
59+| `@address` | address | Contact information |
60+| `@search` | search | Search landmark (HTML5) |
61+| `@breadcrumb` | nav>ol | Semantic breadcrumb navigation |
62+
5763Bare lines (not starting with `@` or `[`) are text nodes.
5864
5965## Syntax
@@ -704,6 +710,8 @@ htmlang deps src/ # show dependency graph (@include/@import)
704710htmlang dead-code src/ # find unused @fn, @define, @let across project
705711htmlang deploy src/ # build and deploy to GitHub Pages
706712htmlang playground # generate self-contained HTML playground
713+htmlang clean src/ # remove generated .html files
714+htmlang outline page.hl # show document structure tree
707715```
708716
709717Watch mode recompiles automatically when the source file or any `@include`d/`@import`ed files change.
@@ -903,6 +911,139 @@ Functions support named `@slot` blocks for multi-region components:
903911
904912Slots not filled by the caller use the default children from the function definition.
905913
914+## New elements
915+
916+### `@script`
917+
918+Embeds JavaScript. Supports inline code and external files via `src`. Children are raw JS (not HTML-escaped).
919+
920+```
921+@script [src app.js, defer]
922+
923+@script
924+ console.log("hello world");
925+ document.addEventListener("DOMContentLoaded", () => {
926+ init();
927+ });
928+```
929+
930+Attributes: `src`, `type`, `defer`, `async`, `crossorigin`, `integrity`, `nomodule`.
931+
932+### `@noscript`
933+
934+Fallback content displayed when JavaScript is disabled.
935+
936+```
937+@noscript
938+ @text This page requires JavaScript.
939+```
940+
941+### `@address`
942+
943+Contact information, rendered as `<address>`.
944+
945+```
946+@address
947+ @text [bold] John Doe
948+ @link mailto:john@example.com john@example.com
949+```
950+
951+### `@search`
952+
953+HTML5 `<search>` landmark for search functionality.
954+
955+```
956+@search
957+ @input [type search, placeholder Search...]
958+ @button [type submit] Search
959+```
960+
961+### `@breadcrumb`
962+
963+Semantic breadcrumb navigation. Generates `<nav aria-label="breadcrumb">` with an `<ol>` list. Each child becomes an `<li>`.
964+
965+```
966+@breadcrumb [spacing 10]
967+ @link / Home
968+ @link /docs Documentation
969+ @text Current Page
970+```
971+
972+## New directives
973+
974+### `@canonical`
975+
976+Sets the canonical URL. Generates `<link rel="canonical">` in the document head.
977+
978+```
979+@canonical https://example.com/my-page
980+```
981+
982+### `@base`
983+
984+Sets the base URL for relative links. Generates `<base href="...">`.
985+
986+```
987+@base https://example.com/
988+```
989+
990+### `@font-face`
991+
992+Declares a custom font. Generates a CSS `@font-face` rule with `font-display: swap`. Format is auto-detected from the file extension.
993+
994+```
995+@font-face Inter fonts/inter.woff2
996+@font-face Mono fonts/jetbrains-mono.ttf
997+```
998+
999+### `@json-ld`
1000+
1001+Structured data for SEO. Generates a `<script type="application/ld+json">` block. Indented body is raw JSON.
1002+
1003+```
1004+@json-ld
1005+ {
1006+ "@context": "https://schema.org",
1007+ "@type": "WebPage",
1008+ "name": "My Site"
1009+ }
1010+```
1011+
1012+## Additional pseudo-states
1013+
1014+| Prefix | CSS Selector | Purpose |
1015+|--------|--------------|---------|
1016+| `visited:` | `:visited` | Visited links |
1017+| `empty:` | `:empty` | Empty elements |
1018+| `target:` | `:target` | URL fragment target |
1019+| `valid:` | `:valid` | Valid form inputs |
1020+| `invalid:` | `:invalid` | Invalid form inputs |
1021+
1022+```
1023+@link [visited:color purple] https://example.com
1024+ Already visited
1025+
1026+@input [type email, valid:border 2 green]
1027+@el [target:background yellow, id section-1]
1028+ Highlighted when navigated to
1029+```
1030+
1031+## Additional CSS properties
1032+
1033+| Attribute | Effect |
1034+|-----------|--------|
1035+| `text-underline-offset N` | Offset of text underline from text |
1036+| `column-width N` | Ideal column width for multi-column layout |
1037+| `column-rule VALUE` | Rule between columns (e.g., `1px solid #ccc`) |
1038+
1039+```
1040+@text [underline, text-underline-offset 4, text-decoration-thickness 2]
1041+ Styled underline
1042+
1043+@el [column-count 3, column-width 200, column-rule 1px solid #ccc]
1044+ Multi-column content
1045+```
1046+
9061047## CSS `@layer` wrapping
9071048
9081049Generated styles are wrapped in `@layer htmlang { ... }` for specificity management. This allows `@style` blocks and `@raw` CSS to easily override generated styles without specificity wars.
@@ -935,6 +1076,23 @@ htmlang deploy src/
9351076
9361077Compiles all `.hl` files, copies static assets, and pushes to a `gh-pages` branch.
9371078
1079+### `htmlang clean`
1080+
1081+Remove generated `.html` files from a directory (matching `.hl` source files). Also removes `sitemap.xml` if present.
1082+
1083+```
1084+htmlang clean src/
1085+htmlang clean # clean current directory
1086+```
1087+
1088+### `htmlang outline`
1089+
1090+Show the document structure tree — a quick overview of elements and nesting without compiling.
1091+
1092+```
1093+htmlang outline page.hl
1094+```
1095+
9381096### `htmlang playground`
9391097
9401098Generate a self-contained HTML playground for experimenting with htmlang:
@@ -942,6 +1100,8 @@ Generate a self-contained HTML playground for experimenting with htmlang:
9421100```
9431101htmlang playground # writes playground.html
9441102htmlang playground my-play.html # custom output path
1103+htmlang clean src/ # remove generated .html files
1104+htmlang outline page.hl # show document structure tree
9451105```
9461106
9471107## Editor support
editors/vscode/snippets/htmlang.json+93
@@ -198,5 +198,98 @@
198198 " $0"
199199 ],
200200 "description": "Extend a base layout template"
201+ },
202+ "Script (external)": {
203+ "prefix": "@script-src",
204+ "body": [
205+ "@script [src ${1:app.js}, ${2:defer}]",
206+ "$0"
207+ ],
208+ "description": "External script tag with src attribute"
209+ },
210+ "Script (inline)": {
211+ "prefix": "@script",
212+ "body": [
213+ "@script",
214+ " $0"
215+ ],
216+ "description": "Inline script block"
217+ },
218+ "Noscript fallback": {
219+ "prefix": "@noscript",
220+ "body": [
221+ "@noscript",
222+ " @text ${1:JavaScript is required for this page.}",
223+ " $0"
224+ ],
225+ "description": "Fallback content when JavaScript is disabled"
226+ },
227+ "Breadcrumb navigation": {
228+ "prefix": "@breadcrumb",
229+ "body": [
230+ "@breadcrumb [spacing ${1:10}]",
231+ " @link ${2:/} ${3:Home}",
232+ " @link ${4:/docs} ${5:Docs}",
233+ " @text ${6:Current Page}",
234+ " $0"
235+ ],
236+ "description": "Breadcrumb navigation with links"
237+ },
238+ "Address block": {
239+ "prefix": "@address",
240+ "body": [
241+ "@address",
242+ " @text [bold] ${1:Name}",
243+ " @link mailto:${2:email@example.com} $2",
244+ " $0"
245+ ],
246+ "description": "Contact address block"
247+ },
248+ "Search form": {
249+ "prefix": "@search",
250+ "body": [
251+ "@search",
252+ " @input [type search, placeholder ${1:Search...}]",
253+ " @button [type submit] ${2:Go}",
254+ " $0"
255+ ],
256+ "description": "Search form with input and submit button"
257+ },
258+ "Canonical URL": {
259+ "prefix": "@canonical",
260+ "body": [
261+ "@canonical ${1:https://example.com/page}",
262+ "$0"
263+ ],
264+ "description": "Canonical URL for the page"
265+ },
266+ "Base URL": {
267+ "prefix": "@base",
268+ "body": [
269+ "@base ${1:https://example.com/}",
270+ "$0"
271+ ],
272+ "description": "Base URL for relative links"
273+ },
274+ "Font face": {
275+ "prefix": "@font-face",
276+ "body": [
277+ "@font-face ${1:Inter} ${2:fonts/${3:inter.woff2}}",
278+ "$0"
279+ ],
280+ "description": "Custom font face declaration"
281+ },
282+ "JSON-LD structured data": {
283+ "prefix": "@json-ld",
284+ "body": [
285+ "@json-ld",
286+ " {",
287+ " \"@context\": \"https://schema.org\",",
288+ " \"@type\": \"${1:WebPage}\",",
289+ " \"name\": \"${2:Page Title}\"",
290+ " }",
291+ "$0"
292+ ],
293+ "description": "JSON-LD structured data block"
201294 }
202295}
editors/vscode/syntaxes/htmlang.tmLanguage.json+2−2
@@ -65,7 +65,7 @@
6565 }
6666 },
6767 "include-directive": {
68− "match": "(@include)\\s+(.*)",
68+ "match": "(@(?:include|import|extends|canonical|base|font-face))\\s+(.*)",
6969 "captures": {
7070 "1": { "name": "keyword.control.directive.htmlang" },
7171 "2": { "name": "string.unquoted.filename.htmlang" }
@@ -73,7 +73,7 @@
7373 },
7474 "element": {
7575 "name": "entity.name.tag.htmlang",
76− "match": "@(?:row|column|col|el|text|paragraph|p|image|img|link|raw|children)\\b"
76+ "match": "@(?:row|column|col|el|text|paragraph|p|image|img|link|raw|children|nav|header|footer|main|section|article|aside|list|item|table|thead|tbody|tr|td|th|form|input|button|select|textarea|label|option|details|summary|blockquote|cite|code|pre|hr|figure|figcaption|video|audio|iframe|canvas|script|noscript|address|search|breadcrumb|grid|stack|spacer|badge|tooltip|avatar|carousel|chip|tag)\\b"
7777 },
7878 "function-call": {
7979 "name": "entity.name.function.htmlang",
src/ast.rs+10
@@ -16,6 +16,10 @@ pub struct Document {
1616 pub og_tags: Vec<(String, String)>,
1717 pub custom_breakpoints: Vec<(String, String)>,
1818 pub theme_tokens: Vec<(String, String)>,
19+ pub canonical: Option<String>,
20+ pub base_url: Option<String>,
21+ pub font_faces: Vec<(String, String)>,
22+ pub json_ld_blocks: Vec<String>,
1923 pub nodes: Vec<Node>,
2024}
2125
@@ -121,6 +125,12 @@ pub enum ElementKind {
121125 Carousel,
122126 Chip,
123127 Tag,
128+ // HTML5 elements
129+ Script,
130+ Noscript,
131+ Address,
132+ Search,
133+ Breadcrumb,
124134}
125135
126136#[derive(Debug, Clone)]
src/bin/htmlang-lsp.rs+45−1
@@ -654,6 +654,11 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
654654 ("@iframe", "Embedded external page (iframe)"),
655655 ("@output", "Form calculation result (output)"),
656656 ("@canvas", "Drawing surface for scripts (canvas)"),
657+ ("@script", "Inline script element (script)"),
658+ ("@noscript", "Fallback content when scripts disabled (noscript)"),
659+ ("@address", "Contact information (address)"),
660+ ("@search", "Search section (search)"),
661+ ("@breadcrumb", "Breadcrumb navigation (nav with aria)"),
657662 ]
658663 .iter()
659664 .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
@@ -691,6 +696,10 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
691696 ("@deprecated", "Mark next @fn as deprecated", "@deprecated "),
692697 ("@extends", "Inherit a layout template and fill @slot blocks", "@extends "),
693698 ("@use", "Selective import of definitions from a file", "@use "),
699+ ("@canonical", "Set canonical URL for the page", "@canonical "),
700+ ("@base", "Set base URL for relative links", "@base "),
701+ ("@font-face", "Define a custom font face", "@font-face"),
702+ ("@json-ld", "Add JSON-LD structured data to head", "@json-ld"),
694703 ]
695704 .iter()
696705 .map(|(name, detail, insert)| {
@@ -1118,6 +1127,16 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
11181127 ("has(", "Style when element has matching children :has()", false),
11191128 // Critical CSS hint
11201129 ("critical", "Mark as above-fold critical CSS", false),
1130+ // New pseudo-state prefixes
1131+ ("visited:", "Style visited links", false),
1132+ ("empty:", "Style when element has no children", false),
1133+ ("target:", "Style when element is the URL fragment target", false),
1134+ ("valid:", "Style when form element is valid", false),
1135+ ("invalid:", "Style when form element is invalid", false),
1136+ // New CSS properties
1137+ ("text-underline-offset", "Offset of text underline from its default position", true),
1138+ ("column-width", "Ideal width of columns in multi-column layout", true),
1139+ ("column-rule", "Rule between columns (width style color)", true),
11211140 ]
11221141 .iter()
11231142 .map(|(name, detail, takes_value)| {
@@ -1528,6 +1547,16 @@ fn hover_builtin(word: &str) -> Option<String> {
15281547 (Some("landscape"), rest)
15291548 } else if let Some(rest) = word.strip_prefix("portrait:") {
15301549 (Some("portrait"), rest)
1550+ } else if let Some(rest) = word.strip_prefix("visited:") {
1551+ (Some("visited"), rest)
1552+ } else if let Some(rest) = word.strip_prefix("empty:") {
1553+ (Some("empty"), rest)
1554+ } else if let Some(rest) = word.strip_prefix("target:") {
1555+ (Some("target"), rest)
1556+ } else if let Some(rest) = word.strip_prefix("valid:") {
1557+ (Some("valid"), rest)
1558+ } else if let Some(rest) = word.strip_prefix("invalid:") {
1559+ (Some("invalid"), rest)
15311560 } else {
15321561 (None, word)
15331562 };
@@ -1626,6 +1655,10 @@ fn hover_builtin(word: &str) -> Option<String> {
16261655 "@deprecated" => "**@deprecated** `<message>`\n\nMarks the next `@fn` as deprecated. Callers get a compile-time warning.\n\n```\n@deprecated Use @new-card instead\n@fn old-card $title\n ...\n```",
16271656 "@extends" => "**@extends** `<file.hl>`\n\nInherit a layout template. Fill named `@slot` blocks.\n\n```\n@extends layout.hl\n@slot content\n My page content\n@slot sidebar\n Sidebar content\n```",
16281657 "@use" => "**@use** `<file.hl> name1, name2`\n\nSelective import: only imports named `@fn`/`@define` definitions.\n\n```\n@use components.hl card, button\n```",
1658+ "@canonical" => "**@canonical** `<url>`\n\nSets the canonical URL for the page. Adds `<link rel=\"canonical\">` to `<head>`.\n\nUsage: `@canonical https://example.com/page`",
1659+ "@base" => "**@base** `<url>`\n\nSets the base URL for all relative URLs in the document. Adds `<base>` to `<head>`.\n\nUsage: `@base https://example.com/`",
1660+ "@font-face" => "**@font-face** \u{2014} Custom font\n\nDefines a custom font face. Generates a CSS `@font-face` rule.\n\n```\n@font-face\n family Inter\n src url(/fonts/Inter.woff2)\n weight 400 700\n```",
1661+ "@json-ld" => "**@json-ld** \u{2014} Structured data\n\nAdds JSON-LD structured data to `<head>` as `<script type=\"application/ld+json\">`.\n\n```\n@json-ld\n type Organization\n name My Company\n url https://example.com\n```",
16291662 // Attributes
16301663 "spacing" | "gap" => "**spacing** `<value>`\n\nGap between children. Supports CSS units (px, rem, em, %).\nMaps to CSS `gap`.",
16311664 "padding" => "**padding** `<value>` | `<y> <x>` | `<t> <h> <b>` | `<t> <r> <b> <l>`\n\nInner padding. Supports CSS units. Accepts 1\u{2013}4 values.",
@@ -1804,6 +1837,11 @@ fn hover_builtin(word: &str) -> Option<String> {
18041837 "@iframe" => "**@iframe** \u{2014} Embedded page\n\nRenders as `<iframe>`.\n\nUsage: `@iframe [width fill, height 400] https://example.com`\n\nAttributes: `sandbox`, `allow`, `allowfullscreen`",
18051838 "@output" => "**@output** \u{2014} Form output\n\nRenders as `<output>`. Displays calculation results in forms.\n\nUsage: `@output [for a b] Result`",
18061839 "@canvas" => "**@canvas** \u{2014} Drawing surface\n\nRenders as `<canvas>`. Use with `@raw` JavaScript for drawing.\n\nUsage: `@canvas [width 400, height 300, id myCanvas]`",
1840+ "@script" => "**@script** \u{2014} Script\n\nRenders as `<script>`. Inline JavaScript.\n\nUsage: `@script console.log(\"hello\")`",
1841+ "@noscript" => "**@noscript** \u{2014} NoScript fallback\n\nRenders as `<noscript>`. Shown when JavaScript is disabled.\n\nUsage: `@noscript Please enable JavaScript.`",
1842+ "@address" => "**@address** \u{2014} Address\n\nRenders as `<address>`. Contact information for the nearest `@article` or `@body`.\n\nUsage: `@address hello@example.com`",
1843+ "@search" => "**@search** \u{2014} Search\n\nRenders as `<search>`. Semantic container for search functionality.\n\nUsage:\n```\n@search\n @input [type search, placeholder Search...]\n @button Search\n```",
1844+ "@breadcrumb" => "**@breadcrumb** \u{2014} Breadcrumb\n\nRenders as `<nav aria-label=\"Breadcrumb\">`. Semantic breadcrumb navigation.\n\nUsage:\n```\n@breadcrumb\n @link / Home\n @link /docs Docs\n @text Current Page\n```",
18071845 // Convenience elements
18081846 "@grid" => "**@grid** \u{2014} CSS Grid container\n\nRenders as `<div>` with `display: grid`.\n\nUsage: `@grid [grid-cols 3, gap 20]`",
18091847 "@stack" => "**@stack** \u{2014} Stack container\n\nRenders as `<div>` with `position: relative`. Children can be absolutely positioned on top of each other.",
@@ -1834,6 +1872,10 @@ fn hover_builtin(word: &str) -> Option<String> {
18341872 "has(" => "**has(selector):** \u{2014} Parent selector pseudo-class.\n\nStyle an element based on its children.\n\nUsage: `@el [has(.active):background blue, has(img):padding 0]`\n\nGenerates CSS `:has()` selector.",
18351873 // Critical
18361874 "critical" => "**critical** \u{2014} Mark element as above-fold.\n\nHint for build tools to prioritize this element's CSS.",
1875+ // New CSS properties
1876+ "text-underline-offset" => "**text-underline-offset** `<value>` \u{2014} Offset of text underline from its default position. Supports CSS units.\n\nUsage: `[underline, text-underline-offset 4]`",
1877+ "column-width" => "**column-width** `<value>` \u{2014} Ideal column width in multi-column layout. Browser determines column count.\n\nUsage: `[column-width 200]`",
1878+ "column-rule" => "**column-rule** `<value>` \u{2014} Rule between columns (shorthand for width, style, color).\n\nUsage: `[column-rule 1px solid #ccc]`",
18371879 // Variable filters
18381880 "\\$|uppercase" => "**|uppercase** \u{2014} Convert variable to UPPERCASE.\n\nUsage: `$name|uppercase`",
18391881 "\\$|lowercase" => "**|lowercase** \u{2014} Convert variable to lowercase.\n\nUsage: `$name|lowercase`",
@@ -2914,7 +2956,8 @@ fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
29142956 | "@include" | "@import" | "@meta" | "@head" | "@style" | "@keyframes"
29152957 | "@match" | "@case" | "@default" | "@slot" | "@children"
29162958 | "@warn" | "@debug" | "@lang" | "@favicon" | "@fragment"
2917− | "@unless" | "@og" | "@breakpoint" => 0, // keyword
2959+ | "@unless" | "@og" | "@breakpoint"
2960+ | "@canonical" | "@base" | "@font-face" | "@json-ld" => 0, // keyword
29182961 _ => {
29192962 // Check if it's a user function call (starts with @ but not a builtin element)
29202963 if is_builtin_element(word) { 0 } else { 2 } // function
@@ -2950,6 +2993,7 @@ fn is_builtin_element(word: &str) -> bool {
29502993 | "@figure" | "@figcaption" | "@progress" | "@meter" | "@fragment"
29512994 | "@dialog" | "@dl" | "@dt" | "@dd" | "@fieldset" | "@legend"
29522995 | "@picture" | "@source" | "@time" | "@mark" | "@kbd" | "@abbr" | "@datalist"
2996+ | "@script" | "@noscript" | "@address" | "@search" | "@breadcrumb"
29532997 )
29542998}
29552999
src/codegen.rs+177−3
@@ -551,6 +551,65 @@ fn generate_with_options(doc: &Document, dev: bool) -> String {
551551 None => String::new(),
552552 };
553553
554+ // Canonical URL
555+ let canonical_html = match &doc.canonical {
556+ Some(url) => {
557+ if dev {
558+ format!("<link rel=\"canonical\" href=\"{}\">\n", html_escape(url))
559+ } else {
560+ format!("<link rel=\"canonical\" href=\"{}\">", html_escape(url))
561+ }
562+ }
563+ None => String::new(),
564+ };
565+
566+ // Base URL
567+ let base_html = match &doc.base_url {
568+ Some(url) => {
569+ if dev {
570+ format!("<base href=\"{}\">\n", html_escape(url))
571+ } else {
572+ format!("<base href=\"{}\">", html_escape(url))
573+ }
574+ }
575+ None => String::new(),
576+ };
577+
578+ // @font-face CSS
579+ for (name, url) in &doc.font_faces {
580+ let format_hint = if url.ends_with(".woff2") {
581+ " format('woff2')"
582+ } else if url.ends_with(".woff") {
583+ " format('woff')"
584+ } else if url.ends_with(".ttf") {
585+ " format('truetype')"
586+ } else if url.ends_with(".otf") {
587+ " format('opentype')"
588+ } else {
589+ ""
590+ };
591+ if dev {
592+ element_css.insert_str(0, &format!(
593+ "@font-face {{\n font-family: '{}';\n src: url('{}'){};\n font-display: swap;\n}}\n",
594+ name, url, format_hint
595+ ));
596+ } else {
597+ element_css.insert_str(0, &format!(
598+ "@font-face{{font-family:'{}';src:url('{}'){};font-display:swap}}",
599+ name, url, format_hint
600+ ));
601+ }
602+ }
603+
604+ // JSON-LD blocks
605+ let json_ld_html: String = doc.json_ld_blocks.iter().map(|block| {
606+ if dev {
607+ format!("<script type=\"application/ld+json\">\n{}\n</script>\n", block)
608+ } else {
609+ format!("<script type=\"application/ld+json\">{}</script>", block)
610+ }
611+ }).collect();
612+
554613 match &doc.page_title {
555614 Some(title) => {
556615 if dev {
@@ -562,7 +621,7 @@ fn generate_with_options(doc: &Document, dev: bool) -> String {
562621<meta charset=\"utf-8\">
563622<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
564623<title>{title}</title>
565−{meta_html}{og_html}{favicon_html}{head_html}\
624+{base_html}{canonical_html}{meta_html}{og_html}{favicon_html}{json_ld_html}{head_html}\
566625<style>
567626*, *::before, *::after {{ box-sizing: border-box; }}
568627body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
@@ -577,8 +636,11 @@ img {{ display: block; }}
577636",
578637 title = html_escape(title),
579638 lang_attr = lang_attr,
639+ base_html = base_html,
640+ canonical_html = canonical_html,
580641 meta_html = meta_html,
581642 favicon_html = favicon_html,
643+ json_ld_html = json_ld_html,
582644 head_html = head_html,
583645 og_html = og_html,
584646 element_css = element_css,
@@ -586,12 +648,15 @@ img {{ display: block; }}
586648 )
587649 } else {
588650 format!(
589− "<!DOCTYPE html><html{lang_attr}><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title>{meta_html}{og_html}{favicon_html}{head_html}<style>*,*::before,*::after{{box-sizing:border-box}}body{{margin:0;font-family:system-ui,-apple-system,sans-serif}}img{{display:block}}{element_css}</style></head><body>{body}</body></html>",
651+ "<!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}{meta_html}{og_html}{favicon_html}{json_ld_html}{head_html}<style>*,*::before,*::after{{box-sizing:border-box}}body{{margin:0;font-family:system-ui,-apple-system,sans-serif}}img{{display:block}}{element_css}</style></head><body>{body}</body></html>",
590652 title = html_escape(title),
591653 lang_attr = lang_attr,
654+ base_html = base_html,
655+ canonical_html = canonical_html,
592656 meta_html = meta_html,
593657 og_html = og_html,
594658 favicon_html = favicon_html,
659+ json_ld_html = json_ld_html,
595660 head_html = head_html,
596661 element_css = element_css,
597662 body = body,
@@ -637,6 +702,7 @@ fn generate_node(
637702 | Some(ElementKind::Dialog) | Some(ElementKind::DefinitionList)
638703 | Some(ElementKind::DefinitionDescription) | Some(ElementKind::Fieldset)
639704 | Some(ElementKind::Datalist) | Some(ElementKind::Grid) | Some(ElementKind::Stack)
705+ | Some(ElementKind::Noscript) | Some(ElementKind::Address) | Some(ElementKind::Search)
640706 );
641707 if needs_wrap {
642708 out.push_str(&ctx.indent());
@@ -685,6 +751,7 @@ const BOOLEAN_HTML_ATTRS: &[&str] = &[
685751 "disabled", "required", "checked", "multiple",
686752 "controls", "autoplay", "loop", "muted",
687753 "open", "novalidate", "autofocus",
754+ "defer", "async", "nomodule",
688755];
689756
690757fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
@@ -721,6 +788,84 @@ fn generate_element(
721788 generate_self_closing(elem, parent_kind, out, styles, ctx);
722789 return;
723790 }
791+ // @script renders as <script> with raw body content (no HTML escaping)
792+ if elem.kind == ElementKind::Script {
793+ out.push_str(&ctx.indent());
794+ out.push_str("<script");
795+ // Pass through src, type, defer, async, etc.
796+ for attr in &elem.attrs {
797+ let key = attr.key.as_str();
798+ if matches!(key, "src" | "type" | "defer" | "async" | "crossorigin" | "integrity" | "nomodule" | "id") {
799+ if let Some(val) = &attr.value {
800+ out.push(' ');
801+ out.push_str(key);
802+ out.push_str("=\"");
803+ out.push_str(&html_escape(val));
804+ out.push('"');
805+ } else {
806+ out.push(' ');
807+ out.push_str(key);
808+ }
809+ }
810+ }
811+ out.push('>');
812+ // Children are raw JS code, not HTML
813+ for child in &elem.children {
814+ match child {
815+ Node::Text(segments) => {
816+ for seg in segments {
817+ match seg {
818+ TextSegment::Plain(text) => out.push_str(text),
819+ _ => {}
820+ }
821+ }
822+ }
823+ Node::Raw(content) => out.push_str(content),
824+ _ => {}
825+ }
826+ }
827+ out.push_str("</script>");
828+ out.push_str(ctx.nl());
829+ return;
830+ }
831+ // @breadcrumb generates semantic <nav aria-label="breadcrumb"><ol>...</ol></nav>
832+ if elem.kind == ElementKind::Breadcrumb {
833+ let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
834+ let (id, user_class) = extract_id_class(&elem.attrs);
835+ out.push_str(&ctx.indent());
836+ out.push_str("<nav aria-label=\"breadcrumb\"");
837+ emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
838+ if let Some(id) = id {
839+ out.push_str(" id=\"");
840+ out.push_str(&html_escape(&id));
841+ out.push('"');
842+ }
843+ out.push('>');
844+ out.push_str(ctx.nl());
845+ ctx.depth += 1;
846+ out.push_str(&ctx.indent());
847+ out.push_str("<ol>");
848+ out.push_str(ctx.nl());
849+ ctx.depth += 1;
850+ for child in &elem.children {
851+ out.push_str(&ctx.indent());
852+ out.push_str("<li>");
853+ let mut buf = String::new();
854+ generate_node(child, Some(&elem.kind), &mut buf, styles, ctx);
855+ out.push_str(buf.trim());
856+ out.push_str("</li>");
857+ out.push_str(ctx.nl());
858+ }
859+ ctx.depth -= 1;
860+ out.push_str(&ctx.indent());
861+ out.push_str("</ol>");
862+ out.push_str(ctx.nl());
863+ ctx.depth -= 1;
864+ out.push_str(&ctx.indent());
865+ out.push_str("</nav>");
866+ out.push_str(ctx.nl());
867+ return;
868+ }
724869 if elem.kind == ElementKind::Children {
725870 return;
726871 }
@@ -803,9 +948,13 @@ fn generate_element(
803948 ElementKind::Carousel => "div",
804949 ElementKind::Chip => "span",
805950 ElementKind::Tag => "span",
951+ ElementKind::Noscript => "noscript",
952+ ElementKind::Address => "address",
953+ ElementKind::Search => "search",
806954 ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule
807955 | ElementKind::Children | ElementKind::Slot(_) | ElementKind::Fragment
808− | ElementKind::Source | ElementKind::Spacer => unreachable!(),
956+ | ElementKind::Source | ElementKind::Spacer
957+ | ElementKind::Script | ElementKind::Breadcrumb => unreachable!(),
809958 };
810959
811960 let kind_label = match elem.kind {
@@ -870,6 +1019,9 @@ fn generate_element(
8701019 ElementKind::Carousel => "carousel",
8711020 ElementKind::Chip => "chip",
8721021 ElementKind::Tag => "tag",
1022+ ElementKind::Noscript => "noscript",
1023+ ElementKind::Address => "address",
1024+ ElementKind::Search => "search",
8731025 _ => "",
8741026 };
8751027
@@ -1270,6 +1422,11 @@ const PSEUDO_PREFIXES: &[(&str, &str)] = &[
12701422 ("before:", "::before"),
12711423 ("after:", "::after"),
12721424 ("selection:", "::selection"),
1425+ ("visited:", ":visited"),
1426+ ("empty:", ":empty"),
1427+ ("target:", ":target"),
1428+ ("valid:", ":valid"),
1429+ ("invalid:", ":invalid"),
12731430];
12741431const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:", "2xl:"];
12751432const MEDIA_PREFIXES: &[&str] = &["dark:", "print:", "motion-safe:", "motion-reduce:", "landscape:", "portrait:"];
@@ -1961,6 +2118,23 @@ fn attrs_to_css(
19612118 push_css(&mut css, "text-decoration-style", v);
19622119 }
19632120 }
2121+ "text-underline-offset" => {
2122+ if let Some(v) = val {
2123+ push_css(&mut css, "text-underline-offset", &css_px(v));
2124+ }
2125+ }
2126+
2127+ // Multi-column
2128+ "column-width" => {
2129+ if let Some(v) = val {
2130+ push_css(&mut css, "column-width", &css_px(v));
2131+ }
2132+ }
2133+ "column-rule" => {
2134+ if let Some(v) = val {
2135+ push_css(&mut css, "column-rule", v);
2136+ }
2137+ }
19642138
19652139 // Place items/self
19662140 "place-items" => {
src/convert.rs+3
@@ -890,6 +890,9 @@ fn emit_element(
890890 "iframe" => "@iframe",
891891 "output" => "@output",
892892 "canvas" => "@canvas",
893+ "noscript" => "@noscript",
894+ "address" => "@address",
895+ "search" => "@search",
893896 // Unknown / raw elements
894897 "script" | "style" | "svg" | "object" | "embed" => {
895898 emit_raw_element(tag, attrs, children, depth, out);
src/fmt.rs+7−2
@@ -27,13 +27,18 @@ fn attr_category(key: &str) -> u8 {
2727 .or_else(|| key.strip_prefix("motion-reduce:"))
2828 .or_else(|| key.strip_prefix("landscape:"))
2929 .or_else(|| key.strip_prefix("portrait:"))
30+ .or_else(|| key.strip_prefix("visited:"))
31+ .or_else(|| key.strip_prefix("empty:"))
32+ .or_else(|| key.strip_prefix("target:"))
33+ .or_else(|| key.strip_prefix("valid:"))
34+ .or_else(|| key.strip_prefix("invalid:"))
3035 .unwrap_or(key);
3136
3237 match base {
3338 // Layout (parent)
3439 "spacing" | "gap" | "gap-x" | "gap-y" | "wrap"
3540 | "grid" | "grid-cols" | "grid-rows"
36− | "column-count" | "column-gap" => 0,
41+ | "column-count" | "column-gap" | "column-width" | "column-rule" => 0,
3742 // Sizing
3843 "width" | "height" | "min-width" | "max-width" | "min-height" | "max-height"
3944 | "flex-grow" | "flex-shrink" | "flex-basis" => 1,
@@ -61,7 +66,7 @@ fn attr_category(key: &str) -> u8 {
6166 | "text-align" | "line-height" | "letter-spacing" | "text-transform"
6267 | "white-space" | "text-overflow" | "word-break" | "overflow-wrap"
6368 | "text-decoration" | "text-decoration-color" | "text-decoration-thickness"
64− | "text-decoration-style" | "list-style"
69+ | "text-decoration-style" | "text-underline-offset" | "list-style"
6570 | "text-indent" | "hyphens" | "writing-mode" => 7,
6671 // Effects & interaction
6772 "transform" | "transition" | "animation" | "cursor" | "backdrop-filter"
src/main.rs+94
@@ -221,6 +221,8 @@ Commands:
221221 dead-code <dir> Find unused @fn, @define, @let across project
222222 deploy <dir> Build and deploy to GitHub Pages
223223 playground [out.html] Generate a self-contained HTML playground
224+ clean [dir] Remove generated .html files
225+ outline <file.hl> Show document structure tree
224226
225227Options:
226228 -w, --watch Watch for changes and recompile
@@ -1712,6 +1714,98 @@ document.getElementById('editor').addEventListener('keydown',e=>{if(e.key==='Ent
17121714 return;
17131715 }
17141716
1717+ // Handle "clean" subcommand — remove generated .html files
1718+ if args.len() >= 2 && args[1] == "clean" {
1719+ let target = if args.len() >= 3 { &args[2] } else { "." };
1720+ let dir = Path::new(target);
1721+ if !dir.is_dir() {
1722+ eprintln!("error: '{}' is not a directory", target);
1723+ process::exit(1);
1724+ }
1725+ let hl_files = collect_hl_files_recursive(dir);
1726+ let mut removed = 0;
1727+ for hl_file in &hl_files {
1728+ let html_file = hl_file.with_extension("html");
1729+ if html_file.exists() {
1730+ match fs::remove_file(&html_file) {
1731+ Ok(()) => {
1732+ eprintln!("removed {}", html_file.display());
1733+ removed += 1;
1734+ }
1735+ Err(e) => eprintln!("error: {}: {}", html_file.display(), e),
1736+ }
1737+ }
1738+ }
1739+ // Also remove sitemap.xml if present
1740+ let sitemap = dir.join("sitemap.xml");
1741+ if sitemap.exists() {
1742+ if let Ok(()) = fs::remove_file(&sitemap) {
1743+ eprintln!("removed {}", sitemap.display());
1744+ removed += 1;
1745+ }
1746+ }
1747+ eprintln!("cleaned {} file{}", removed, if removed == 1 { "" } else { "s" });
1748+ return;
1749+ }
1750+
1751+ // Handle "outline" subcommand — show document structure
1752+ if args.len() >= 2 && args[1] == "outline" {
1753+ if args.len() < 3 {
1754+ eprintln!("usage: htmlang outline <file.hl>");
1755+ process::exit(1);
1756+ }
1757+ let path = &args[2];
1758+ let input = match fs::read_to_string(path) {
1759+ Ok(s) => s,
1760+ Err(e) => {
1761+ eprintln!("error: {}: {}", path, e);
1762+ process::exit(1);
1763+ }
1764+ };
1765+ let base = Path::new(path).parent();
1766+ let result = htmlang::parser::parse_with_base(&input, base);
1767+ if let Some(title) = &result.document.page_title {
1768+ eprintln!("@page {}", title);
1769+ }
1770+ fn print_outline(nodes: &[htmlang::ast::Node], depth: usize) {
1771+ let indent = " ".repeat(depth);
1772+ for node in nodes {
1773+ match node {
1774+ htmlang::ast::Node::Element(elem) => {
1775+ let kind = format!("{:?}", elem.kind);
1776+ let kind = kind.split('(').next().unwrap_or(&kind);
1777+ let label = if let Some(arg) = &elem.argument {
1778+ format!("@{} {}", kind.to_lowercase(), arg)
1779+ } else {
1780+ format!("@{}", kind.to_lowercase())
1781+ };
1782+ eprintln!("{}{}", indent, label);
1783+ print_outline(&elem.children, depth + 1);
1784+ }
1785+ htmlang::ast::Node::Text(segs) => {
1786+ let text: String = segs.iter().filter_map(|s| match s {
1787+ htmlang::ast::TextSegment::Plain(t) => Some(t.as_str()),
1788+ _ => None,
1789+ }).collect();
1790+ if !text.trim().is_empty() {
1791+ let display = if text.len() > 40 {
1792+ format!("{}...", &text[..37])
1793+ } else {
1794+ text
1795+ };
1796+ eprintln!("{}\"{}\"", indent, display.trim());
1797+ }
1798+ }
1799+ htmlang::ast::Node::Raw(_) => {
1800+ eprintln!("{}@raw ...", indent);
1801+ }
1802+ }
1803+ }
1804+ }
1805+ print_outline(&result.document.nodes, 0);
1806+ return;
1807+ }
1808+
17151809 // Handle "convert" subcommand
17161810 if args.len() >= 2 && args[1] == "convert" {
17171811 if args.len() < 3 {
src/parser.rs+89
@@ -100,6 +100,14 @@ struct ParseContext {
100100 deprecated_fns: HashMap<String, String>,
101101 /// Theme tokens: (name, value) pairs from @theme
102102 theme_tokens: Vec<(String, String)>,
103+ /// Canonical URL
104+ canonical: Option<String>,
105+ /// Base URL for relative links
106+ base_url: Option<String>,
107+ /// @font-face declarations: (font_name, url)
108+ font_faces: Vec<(String, String)>,
109+ /// @json-ld blocks
110+ json_ld_blocks: Vec<String>,
103111}
104112
105113struct Parser {
@@ -142,6 +150,10 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
142150 define_lines: HashMap::new(),
143151 deprecated_fns: HashMap::new(),
144152 theme_tokens: Vec::new(),
153+ canonical: None,
154+ base_url: None,
155+ font_faces: Vec::new(),
156+ json_ld_blocks: Vec::new(),
145157 };
146158 let nodes = parser.parse_children(0, &mut ctx);
147159 validate_tree(&nodes, None, &mut ctx.diagnostics);
@@ -161,6 +173,10 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
161173 og_tags: ctx.og_tags,
162174 custom_breakpoints: ctx.custom_breakpoints,
163175 theme_tokens: ctx.theme_tokens,
176+ canonical: ctx.canonical,
177+ base_url: ctx.base_url,
178+ font_faces: ctx.font_faces,
179+ json_ld_blocks: ctx.json_ld_blocks,
164180 nodes,
165181 },
166182 diagnostics: ctx.diagnostics,
@@ -1093,6 +1109,57 @@ impl Parser {
10931109 return Ok(None);
10941110 }
10951111
1112+ // --- @canonical directive ---
1113+
1114+ if let Some(rest) = content.strip_prefix("@canonical ") {
1115+ ctx.canonical = Some(substitute_vars(rest.trim(), &ctx.variables));
1116+ return Ok(None);
1117+ }
1118+
1119+ // --- @base directive ---
1120+
1121+ if let Some(rest) = content.strip_prefix("@base ") {
1122+ ctx.base_url = Some(substitute_vars(rest.trim(), &ctx.variables));
1123+ return Ok(None);
1124+ }
1125+
1126+ // --- @font-face directive ---
1127+
1128+ if let Some(rest) = content.strip_prefix("@font-face ") {
1129+ let rest = rest.trim();
1130+ if let Some((name, url)) = rest.split_once(' ') {
1131+ ctx.font_faces.push((
1132+ substitute_vars(name.trim(), &ctx.variables),
1133+ substitute_vars(url.trim(), &ctx.variables),
1134+ ));
1135+ }
1136+ return Ok(None);
1137+ }
1138+
1139+ // --- @json-ld block ---
1140+
1141+ if content.trim() == "@json-ld" {
1142+ let mut block = String::new();
1143+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1144+ match &self.lines[self.pos].content {
1145+ LineContent::Normal(s) => {
1146+ block.push_str(s.trim());
1147+ block.push('\n');
1148+ }
1149+ LineContent::Raw(s) => {
1150+ block.push_str(s);
1151+ block.push('\n');
1152+ }
1153+ }
1154+ self.pos += 1;
1155+ }
1156+ let trimmed = block.trim().to_string();
1157+ if !trimmed.is_empty() {
1158+ ctx.json_ld_blocks.push(trimmed);
1159+ }
1160+ return Ok(None);
1161+ }
1162+
10961163 // --- @deprecated annotation ---
10971164
10981165 if let Some(rest) = content.strip_prefix("@deprecated ") {
@@ -1540,6 +1607,7 @@ const KNOWN_ELEMENTS: &[&str] = &[
15401607 "iframe", "output", "canvas",
15411608 "grid", "stack", "spacer", "badge", "tooltip",
15421609 "avatar", "carousel", "chip", "tag",
1610+ "script", "noscript", "address", "search", "breadcrumb",
15431611];
15441612
15451613const KNOWN_DIRECTIVES: &[&str] = &[
@@ -1548,6 +1616,7 @@ const KNOWN_DIRECTIVES: &[&str] = &[
15481616 "match", "case", "default", "warn", "debug",
15491617 "unless", "og", "breakpoint", "lang", "favicon",
15501618 "use", "theme", "deprecated", "extends",
1619+ "canonical", "base", "font-face", "json-ld",
15511620];
15521621
15531622fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
@@ -1623,6 +1692,11 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
16231692 "carousel" => Ok(ElementKind::Carousel),
16241693 "chip" => Ok(ElementKind::Chip),
16251694 "tag" => Ok(ElementKind::Tag),
1695+ "script" => Ok(ElementKind::Script),
1696+ "noscript" => Ok(ElementKind::Noscript),
1697+ "address" => Ok(ElementKind::Address),
1698+ "search" => Ok(ElementKind::Search),
1699+ "breadcrumb" => Ok(ElementKind::Breadcrumb),
16261700 _ => {
16271701 let all_known: Vec<&str> = KNOWN_ELEMENTS
16281702 .iter()
@@ -1756,6 +1830,8 @@ const KNOWN_ATTRS: &[&str] = &[
17561830 "border-collapse", "border-spacing",
17571831 // Text decoration variants
17581832 "text-decoration", "text-decoration-color", "text-decoration-thickness", "text-decoration-style",
1833+ "text-underline-offset",
1834+ "column-width", "column-rule",
17591835 // Grid/flex placement
17601836 "place-items", "place-self",
17611837 // Scroll behavior
@@ -1775,6 +1851,8 @@ const KNOWN_ATTRS: &[&str] = &[
17751851 // Iframe/output attrs
17761852 "sandbox", "allow", "allowfullscreen", "referrerpolicy",
17771853 "formaction", "formmethod", "formtarget", "target",
1854+ // Script attributes
1855+ "defer", "async", "crossorigin", "integrity", "nomodule",
17781856 // Pseudo-element content
17791857 "content",
17801858 // CSS shorthands
@@ -2279,6 +2357,11 @@ fn strip_all_prefixes(key: &str) -> &str {
22792357 .or_else(|| key.strip_prefix("selection:"))
22802358 .or_else(|| key.strip_prefix("before:"))
22812359 .or_else(|| key.strip_prefix("after:"))
2360+ .or_else(|| key.strip_prefix("visited:"))
2361+ .or_else(|| key.strip_prefix("empty:"))
2362+ .or_else(|| key.strip_prefix("target:"))
2363+ .or_else(|| key.strip_prefix("valid:"))
2364+ .or_else(|| key.strip_prefix("invalid:"))
22822365 .or_else(|| {
22832366 // Handle nth:EXPR: prefix (e.g., nth:3:background -> background)
22842367 if key.starts_with("nth:") {
@@ -2406,6 +2489,11 @@ fn element_kind_name(kind: &ElementKind) -> &'static str {
24062489 ElementKind::Carousel => "@carousel",
24072490 ElementKind::Chip => "@chip",
24082491 ElementKind::Tag => "@tag",
2492+ ElementKind::Script => "@script",
2493+ ElementKind::Noscript => "@noscript",
2494+ ElementKind::Address => "@address",
2495+ ElementKind::Search => "@search",
2496+ ElementKind::Breadcrumb => "@breadcrumb",
24092497 }
24102498}
24112499
@@ -2421,6 +2509,7 @@ fn is_container(kind: &ElementKind) -> bool {
24212509 | ElementKind::DefinitionDescription | ElementKind::Fieldset | ElementKind::Datalist
24222510 | ElementKind::Iframe | ElementKind::Canvas
24232511 | ElementKind::Grid | ElementKind::Stack | ElementKind::Carousel
2512+ | ElementKind::Noscript | ElementKind::Address | ElementKind::Search | ElementKind::Breadcrumb
24242513 )
24252514}
24262515
tests/snapshots.rs+169
@@ -2715,3 +2715,172 @@ fn no_warning_new_attrs_batch6() {
27152715 diags
27162716 );
27172717}
2718+
2719+// --- New elements (batch 6) ---
2720+
2721+#[test]
2722+fn snapshot_new_elements_6() {
2723+ snapshot_test("new_elements_6");
2724+}
2725+
2726+#[test]
2727+fn snapshot_script_element() {
2728+ snapshot_test("script_element");
2729+}
2730+
2731+#[test]
2732+fn snapshot_breadcrumb() {
2733+ snapshot_test("breadcrumb");
2734+}
2735+
2736+#[test]
2737+fn snapshot_new_directives() {
2738+ snapshot_test("new_directives");
2739+}
2740+
2741+#[test]
2742+fn snapshot_new_pseudos() {
2743+ snapshot_test("new_pseudos");
2744+}
2745+
2746+#[test]
2747+fn snapshot_new_css_properties_4() {
2748+ snapshot_test("new_css_properties_4");
2749+}
2750+
2751+// --- Assertion tests for new features ---
2752+
2753+#[test]
2754+fn script_element_with_src() {
2755+ let output = compile("@script [src app.js, defer]");
2756+ assert!(output.contains("<script src=\"app.js\" defer>"), "script src: {}", output);
2757+ assert!(output.contains("</script>"), "script close: {}", output);
2758+}
2759+
2760+#[test]
2761+fn script_element_inline() {
2762+ let output = compile("@script\n console.log(42);");
2763+ assert!(output.contains("<script>console.log(42);</script>"), "inline script: {}", output);
2764+}
2765+
2766+#[test]
2767+fn noscript_element() {
2768+ let output = compile("@noscript\n @text Fallback");
2769+ assert!(output.contains("<noscript>"), "noscript open: {}", output);
2770+ assert!(output.contains("</noscript>"), "noscript close: {}", output);
2771+}
2772+
2773+#[test]
2774+fn address_element() {
2775+ let output = compile("@address\n @text Contact");
2776+ assert!(output.contains("<address>"), "address: {}", output);
2777+}
2778+
2779+#[test]
2780+fn search_element() {
2781+ let output = compile("@search\n @input [type search]");
2782+ assert!(output.contains("<search>"), "search: {}", output);
2783+}
2784+
2785+#[test]
2786+fn breadcrumb_generates_nav_ol() {
2787+ let output = compile("@breadcrumb\n @text Home\n @text About");
2788+ assert!(output.contains("<nav aria-label=\"breadcrumb\">"), "nav: {}", output);
2789+ assert!(output.contains("<ol>"), "ol: {}", output);
2790+ assert!(output.contains("<li>"), "li: {}", output);
2791+}
2792+
2793+#[test]
2794+fn canonical_directive() {
2795+ let output = compile("@page T\n@canonical https://example.com/page\n@text Hello");
2796+ assert!(output.contains("<link rel=\"canonical\" href=\"https://example.com/page\">"), "canonical: {}", output);
2797+}
2798+
2799+#[test]
2800+fn base_directive() {
2801+ let output = compile("@page T\n@base https://example.com/\n@text Hello");
2802+ assert!(output.contains("<base href=\"https://example.com/\">"), "base: {}", output);
2803+}
2804+
2805+#[test]
2806+fn font_face_directive() {
2807+ let output = compile("@page T\n@font-face Inter fonts/inter.woff2\n@text Hello");
2808+ assert!(output.contains("@font-face"), "font-face: {}", output);
2809+ assert!(output.contains("font-family:'Inter'"), "font name: {}", output);
2810+ assert!(output.contains("fonts/inter.woff2"), "font url: {}", output);
2811+ assert!(output.contains("woff2"), "format hint: {}", output);
2812+}
2813+
2814+#[test]
2815+fn json_ld_directive() {
2816+ let output = compile("@page T\n@json-ld\n {\"@type\": \"WebPage\"}\n@text Hello");
2817+ assert!(output.contains("application/ld+json"), "json-ld type: {}", output);
2818+ assert!(output.contains("WebPage"), "json-ld content: {}", output);
2819+}
2820+
2821+#[test]
2822+fn visited_pseudo() {
2823+ let output = compile("@link [visited:color purple] https://example.com\n Test");
2824+ assert!(output.contains(":visited"), "visited pseudo: {}", output);
2825+ assert!(output.contains("color:purple"), "visited color: {}", output);
2826+}
2827+
2828+#[test]
2829+fn empty_pseudo() {
2830+ let output = compile("@el [empty:display none]\n Content");
2831+ assert!(output.contains(":empty"), "empty pseudo: {}", output);
2832+}
2833+
2834+#[test]
2835+fn target_pseudo() {
2836+ let output = compile("@el [target:background yellow]\n Content");
2837+ assert!(output.contains(":target"), "target pseudo: {}", output);
2838+}
2839+
2840+#[test]
2841+fn valid_invalid_pseudo() {
2842+ let output = compile("@input [type email, valid:border 2 green]");
2843+ assert!(output.contains(":valid"), "valid pseudo: {}", output);
2844+}
2845+
2846+#[test]
2847+fn text_underline_offset_property() {
2848+ let output = compile("@text [underline, text-underline-offset 4] Link");
2849+ assert!(output.contains("text-underline-offset:4px"), "text-underline-offset: {}", output);
2850+}
2851+
2852+#[test]
2853+fn column_width_property() {
2854+ let output = compile("@el [column-width 200]\n Content");
2855+ assert!(output.contains("column-width:200px"), "column-width: {}", output);
2856+}
2857+
2858+#[test]
2859+fn column_rule_property() {
2860+ let output = compile("@el [column-rule 1px solid #ccc]\n Content");
2861+ assert!(output.contains("column-rule:1px solid #ccc"), "column-rule: {}", output);
2862+}
2863+
2864+#[test]
2865+fn no_warning_new_attrs_batch7() {
2866+ let diags = parse_diagnostics(
2867+ "@el [text-underline-offset 4, column-width 200, column-rule 1px solid gray]"
2868+ );
2869+ assert!(
2870+ !diags.iter().any(|d| d.message.contains("unknown attribute")),
2871+ "new CSS properties should be recognized: {:?}",
2872+ diags
2873+ );
2874+}
2875+
2876+#[test]
2877+fn no_warning_script_attrs() {
2878+ let diags = parse_diagnostics(
2879+ "@script [src app.js, defer, async, crossorigin anonymous, integrity sha384-abc, nomodule]"
2880+ );
2881+ assert!(
2882+ !diags.iter().any(|d| d.message.contains("unknown attribute")),
2883+ "script attributes should be recognized: {:?}",
2884+ diags
2885+ );
2886+}
tests/snapshots/breadcrumb.hlnew file+4
@@ -0,0 +1,4 @@
1+@breadcrumb [spacing 10]
2+ @link / Home
3+ @link /docs Docs
4+ @text Current Page
tests/snapshots/new_css_properties_4.hlnew file+5
@@ -0,0 +1,5 @@
1+@text [underline, text-underline-offset 4, text-decoration-thickness 2]
2+ Styled underline
3+
4+@el [column-count 3, column-width 200, column-rule 1px solid #ccc]
5+ Multi-column layout content
tests/snapshots/new_directives.hlnew file+14
@@ -0,0 +1,14 @@
1+@page My Site
2+@canonical https://example.com/page
3+@base https://example.com/
4+@font-face Inter fonts/inter.woff2
5+@font-face Mono fonts/mono.ttf
6+@json-ld
7+ {
8+ "@context": "https://schema.org",
9+ "@type": "WebPage",
10+ "name": "My Site"
11+ }
12+
13+@column [spacing 10]
14+ @text [font Inter] Hello with custom fonts
tests/snapshots/new_elements_6.hlnew file+10
@@ -0,0 +1,10 @@
1+@noscript
2+ @text JavaScript is required for this page.
3+
4+@address
5+ @text [bold] John Doe
6+ @link mailto:john@example.com john@example.com
7+
8+@search
9+ @input [type search, placeholder Search...]
10+ @button [type submit] Go
tests/snapshots/new_pseudos.hlnew file+8
@@ -0,0 +1,8 @@
1+@link [visited:color purple] https://example.com
2+ Already visited
3+
4+@el [empty:display none, target:background yellow]
5+ Conditional content
6+
7+@input [type email, valid:border 2 green]
8+@input [type text, invalid:border 2 red]
tests/snapshots/script_element.hlnew file+7
@@ -0,0 +1,7 @@
1+@script [src app.js, defer]
2+
3+@script
4+ console.log("hello world");
5+ document.addEventListener("DOMContentLoaded", () => {
6+ alert("loaded");
7+ });