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
39f698diko
DESIGN.md+160
@@ -54,6 +54,12 @@ A minimalist layout language inspired by elm-ui that compiles to static HTML.
54| `@output` | output | Form calculation result |
54| `@output` | output | Form calculation result |
55| `@canvas` | canvas | Drawing surface for scripts |
55| `@canvas` | canvas | Drawing surface for scripts |
56
56
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+
57Bare lines (not starting with `@` or `[`) are text nodes.
63Bare lines (not starting with `@` or `[`) are text nodes.
58
64
59## Syntax
65## Syntax
@@ -704,6 +710,8 @@ htmlang deps src/ # show dependency graph (@include/@import)
704htmlang dead-code src/ # find unused @fn, @define, @let across project
710htmlang dead-code src/ # find unused @fn, @define, @let across project
705htmlang deploy src/ # build and deploy to GitHub Pages
711htmlang deploy src/ # build and deploy to GitHub Pages
706htmlang playground # generate self-contained HTML playground
712htmlang playground # generate self-contained HTML playground
713+htmlang clean src/ # remove generated .html files
714+htmlang outline page.hl # show document structure tree
707```
715```
708
716
709Watch mode recompiles automatically when the source file or any `@include`d/`@import`ed files change.
717Watch 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:
903
911
904Slots not filled by the caller use the default children from the function definition.
912Slots not filled by the caller use the default children from the function definition.
905
913
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+
906## CSS `@layer` wrapping
1047## CSS `@layer` wrapping
907
1048
908Generated styles are wrapped in `@layer htmlang { ... }` for specificity management. This allows `@style` blocks and `@raw` CSS to easily override generated styles without specificity wars.
1049Generated 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/
935
1076
936Compiles all `.hl` files, copies static assets, and pushes to a `gh-pages` branch.
1077Compiles all `.hl` files, copies static assets, and pushes to a `gh-pages` branch.
937
1078
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+
938### `htmlang playground`
1096### `htmlang playground`
939
1097
940Generate a self-contained HTML playground for experimenting with htmlang:
1098Generate a self-contained HTML playground for experimenting with htmlang:
@@ -942,6 +1100,8 @@ Generate a self-contained HTML playground for experimenting with htmlang:
942```
1100```
943htmlang playground # writes playground.html
1101htmlang playground # writes playground.html
944htmlang playground my-play.html # custom output path
1102htmlang playground my-play.html # custom output path
1103+htmlang clean src/ # remove generated .html files
1104+htmlang outline page.hl # show document structure tree
945```
1105```
946
1106
947## Editor support
1107## Editor support
editors/vscode/snippets/htmlang.json+93
@@ -198,5 +198,98 @@
198 " $0"
198 " $0"
199 ],
199 ],
200 "description": "Extend a base layout template"
200 "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"
201 }
294 }
202}
295}
editors/vscode/syntaxes/htmlang.tmLanguage.json+2−2
@@ -65,7 +65,7 @@
65 }
65 }
66 },
66 },
67 "include-directive": {
67 "include-directive": {
68− "match": "(@include)\\s+(.*)",
68+ "match": "(@(?:include|import|extends|canonical|base|font-face))\\s+(.*)",
69 "captures": {
69 "captures": {
70 "1": { "name": "keyword.control.directive.htmlang" },
70 "1": { "name": "keyword.control.directive.htmlang" },
71 "2": { "name": "string.unquoted.filename.htmlang" }
71 "2": { "name": "string.unquoted.filename.htmlang" }
@@ -73,7 +73,7 @@
73 },
73 },
74 "element": {
74 "element": {
75 "name": "entity.name.tag.htmlang",
75 "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"
77 },
77 },
78 "function-call": {
78 "function-call": {
79 "name": "entity.name.function.htmlang",
79 "name": "entity.name.function.htmlang",
src/ast.rs+10
@@ -16,6 +16,10 @@ pub struct Document {
16 pub og_tags: Vec<(String, String)>,
16 pub og_tags: Vec<(String, String)>,
17 pub custom_breakpoints: Vec<(String, String)>,
17 pub custom_breakpoints: Vec<(String, String)>,
18 pub theme_tokens: Vec<(String, String)>,
18 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>,
19 pub nodes: Vec<Node>,
23 pub nodes: Vec<Node>,
20}
24}
21
25
@@ -121,6 +125,12 @@ pub enum ElementKind {
121 Carousel,
125 Carousel,
122 Chip,
126 Chip,
123 Tag,
127 Tag,
128+ // HTML5 elements
129+ Script,
130+ Noscript,
131+ Address,
132+ Search,
133+ Breadcrumb,
124}
134}
125
135
126#[derive(Debug, Clone)]
136#[derive(Debug, Clone)]
src/bin/htmlang-lsp.rs+45−1
@@ -654,6 +654,11 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
654 ("@iframe", "Embedded external page (iframe)"),
654 ("@iframe", "Embedded external page (iframe)"),
655 ("@output", "Form calculation result (output)"),
655 ("@output", "Form calculation result (output)"),
656 ("@canvas", "Drawing surface for scripts (canvas)"),
656 ("@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)"),
657 ]
662 ]
658 .iter()
663 .iter()
659 .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
664 .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
@@ -691,6 +696,10 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
691 ("@deprecated", "Mark next @fn as deprecated", "@deprecated "),
696 ("@deprecated", "Mark next @fn as deprecated", "@deprecated "),
692 ("@extends", "Inherit a layout template and fill @slot blocks", "@extends "),
697 ("@extends", "Inherit a layout template and fill @slot blocks", "@extends "),
693 ("@use", "Selective import of definitions from a file", "@use "),
698 ("@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"),
694 ]
703 ]
695 .iter()
704 .iter()
696 .map(|(name, detail, insert)| {
705 .map(|(name, detail, insert)| {
@@ -1118,6 +1127,16 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
1118 ("has(", "Style when element has matching children :has()", false),
1127 ("has(", "Style when element has matching children :has()", false),
1119 // Critical CSS hint
1128 // Critical CSS hint
1120 ("critical", "Mark as above-fold critical CSS", false),
1129 ("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),
1121 ]
1140 ]
1122 .iter()
1141 .iter()
1123 .map(|(name, detail, takes_value)| {
1142 .map(|(name, detail, takes_value)| {
@@ -1528,6 +1547,16 @@ fn hover_builtin(word: &str) -> Option<String> {
1528 (Some("landscape"), rest)
1547 (Some("landscape"), rest)
1529 } else if let Some(rest) = word.strip_prefix("portrait:") {
1548 } else if let Some(rest) = word.strip_prefix("portrait:") {
1530 (Some("portrait"), rest)
1549 (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)
1531 } else {
1560 } else {
1532 (None, word)
1561 (None, word)
1533 };
1562 };
@@ -1626,6 +1655,10 @@ fn hover_builtin(word: &str) -> Option<String> {
1626 "@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```",
1655 "@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```",
1627 "@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```",
1656 "@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```",
1628 "@use" => "**@use** `<file.hl> name1, name2`\n\nSelective import: only imports named `@fn`/`@define` definitions.\n\n```\n@use components.hl card, button\n```",
1657 "@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```",
1629 // Attributes
1662 // Attributes
1630 "spacing" | "gap" => "**spacing** `<value>`\n\nGap between children. Supports CSS units (px, rem, em, %).\nMaps to CSS `gap`.",
1663 "spacing" | "gap" => "**spacing** `<value>`\n\nGap between children. Supports CSS units (px, rem, em, %).\nMaps to CSS `gap`.",
1631 "padding" => "**padding** `<value>` | `<y> <x>` | `<t> <h> <b>` | `<t> <r> <b> <l>`\n\nInner padding. Supports CSS units. Accepts 1\u{2013}4 values.",
1664 "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> {
1804 "@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`",
1837 "@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`",
1805 "@output" => "**@output** \u{2014} Form output\n\nRenders as `<output>`. Displays calculation results in forms.\n\nUsage: `@output [for a b] Result`",
1838 "@output" => "**@output** \u{2014} Form output\n\nRenders as `<output>`. Displays calculation results in forms.\n\nUsage: `@output [for a b] Result`",
1806 "@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]`",
1839 "@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```",
1807 // Convenience elements
1845 // Convenience elements
1808 "@grid" => "**@grid** \u{2014} CSS Grid container\n\nRenders as `<div>` with `display: grid`.\n\nUsage: `@grid [grid-cols 3, gap 20]`",
1846 "@grid" => "**@grid** \u{2014} CSS Grid container\n\nRenders as `<div>` with `display: grid`.\n\nUsage: `@grid [grid-cols 3, gap 20]`",
1809 "@stack" => "**@stack** \u{2014} Stack container\n\nRenders as `<div>` with `position: relative`. Children can be absolutely positioned on top of each other.",
1847 "@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> {
1834 "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.",
1872 "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.",
1835 // Critical
1873 // Critical
1836 "critical" => "**critical** \u{2014} Mark element as above-fold.\n\nHint for build tools to prioritize this element's CSS.",
1874 "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]`",
1837 // Variable filters
1879 // Variable filters
1838 "\\$|uppercase" => "**|uppercase** \u{2014} Convert variable to UPPERCASE.\n\nUsage: `$name|uppercase`",
1880 "\\$|uppercase" => "**|uppercase** \u{2014} Convert variable to UPPERCASE.\n\nUsage: `$name|uppercase`",
1839 "\\$|lowercase" => "**|lowercase** \u{2014} Convert variable to lowercase.\n\nUsage: `$name|lowercase`",
1881 "\\$|lowercase" => "**|lowercase** \u{2014} Convert variable to lowercase.\n\nUsage: `$name|lowercase`",
@@ -2914,7 +2956,8 @@ fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
2914 | "@include" | "@import" | "@meta" | "@head" | "@style" | "@keyframes"
2956 | "@include" | "@import" | "@meta" | "@head" | "@style" | "@keyframes"
2915 | "@match" | "@case" | "@default" | "@slot" | "@children"
2957 | "@match" | "@case" | "@default" | "@slot" | "@children"
2916 | "@warn" | "@debug" | "@lang" | "@favicon" | "@fragment"
2958 | "@warn" | "@debug" | "@lang" | "@favicon" | "@fragment"
2917− | "@unless" | "@og" | "@breakpoint" => 0, // keyword
2959+ | "@unless" | "@og" | "@breakpoint"
2960+ | "@canonical" | "@base" | "@font-face" | "@json-ld" => 0, // keyword
2918 _ => {
2961 _ => {
2919 // Check if it's a user function call (starts with @ but not a builtin element)
2962 // Check if it's a user function call (starts with @ but not a builtin element)
2920 if is_builtin_element(word) { 0 } else { 2 } // function
2963 if is_builtin_element(word) { 0 } else { 2 } // function
@@ -2950,6 +2993,7 @@ fn is_builtin_element(word: &str) -> bool {
2950 | "@figure" | "@figcaption" | "@progress" | "@meter" | "@fragment"
2993 | "@figure" | "@figcaption" | "@progress" | "@meter" | "@fragment"
2951 | "@dialog" | "@dl" | "@dt" | "@dd" | "@fieldset" | "@legend"
2994 | "@dialog" | "@dl" | "@dt" | "@dd" | "@fieldset" | "@legend"
2952 | "@picture" | "@source" | "@time" | "@mark" | "@kbd" | "@abbr" | "@datalist"
2995 | "@picture" | "@source" | "@time" | "@mark" | "@kbd" | "@abbr" | "@datalist"
2996+ | "@script" | "@noscript" | "@address" | "@search" | "@breadcrumb"
2953 )
2997 )
2954}
2998}
2955
2999
src/codegen.rs+177−3
@@ -551,6 +551,65 @@ fn generate_with_options(doc: &Document, dev: bool) -> String {
551 None => String::new(),
551 None => String::new(),
552 };
552 };
553
553
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+
554 match &doc.page_title {
613 match &doc.page_title {
555 Some(title) => {
614 Some(title) => {
556 if dev {
615 if dev {
@@ -562,7 +621,7 @@ fn generate_with_options(doc: &Document, dev: bool) -> String {
562<meta charset=\"utf-8\">
621<meta charset=\"utf-8\">
563<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
622<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
564<title>{title}</title>
623<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}\
566<style>
625<style>
567*, *::before, *::after {{ box-sizing: border-box; }}
626*, *::before, *::after {{ box-sizing: border-box; }}
568body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
627body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
@@ -577,8 +636,11 @@ img {{ display: block; }}
577",
636",
578 title = html_escape(title),
637 title = html_escape(title),
579 lang_attr = lang_attr,
638 lang_attr = lang_attr,
639+ base_html = base_html,
640+ canonical_html = canonical_html,
580 meta_html = meta_html,
641 meta_html = meta_html,
581 favicon_html = favicon_html,
642 favicon_html = favicon_html,
643+ json_ld_html = json_ld_html,
582 head_html = head_html,
644 head_html = head_html,
583 og_html = og_html,
645 og_html = og_html,
584 element_css = element_css,
646 element_css = element_css,
@@ -586,12 +648,15 @@ img {{ display: block; }}
586 )
648 )
587 } else {
649 } else {
588 format!(
650 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>",
590 title = html_escape(title),
652 title = html_escape(title),
591 lang_attr = lang_attr,
653 lang_attr = lang_attr,
654+ base_html = base_html,
655+ canonical_html = canonical_html,
592 meta_html = meta_html,
656 meta_html = meta_html,
593 og_html = og_html,
657 og_html = og_html,
594 favicon_html = favicon_html,
658 favicon_html = favicon_html,
659+ json_ld_html = json_ld_html,
595 head_html = head_html,
660 head_html = head_html,
596 element_css = element_css,
661 element_css = element_css,
597 body = body,
662 body = body,
@@ -637,6 +702,7 @@ fn generate_node(
637 | Some(ElementKind::Dialog) | Some(ElementKind::DefinitionList)
702 | Some(ElementKind::Dialog) | Some(ElementKind::DefinitionList)
638 | Some(ElementKind::DefinitionDescription) | Some(ElementKind::Fieldset)
703 | Some(ElementKind::DefinitionDescription) | Some(ElementKind::Fieldset)
639 | Some(ElementKind::Datalist) | Some(ElementKind::Grid) | Some(ElementKind::Stack)
704 | Some(ElementKind::Datalist) | Some(ElementKind::Grid) | Some(ElementKind::Stack)
705+ | Some(ElementKind::Noscript) | Some(ElementKind::Address) | Some(ElementKind::Search)
640 );
706 );
641 if needs_wrap {
707 if needs_wrap {
642 out.push_str(&ctx.indent());
708 out.push_str(&ctx.indent());
@@ -685,6 +751,7 @@ const BOOLEAN_HTML_ATTRS: &[&str] = &[
685 "disabled", "required", "checked", "multiple",
751 "disabled", "required", "checked", "multiple",
686 "controls", "autoplay", "loop", "muted",
752 "controls", "autoplay", "loop", "muted",
687 "open", "novalidate", "autofocus",
753 "open", "novalidate", "autofocus",
754+ "defer", "async", "nomodule",
688];
755];
689
756
690fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
757fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
@@ -721,6 +788,84 @@ fn generate_element(
721 generate_self_closing(elem, parent_kind, out, styles, ctx);
788 generate_self_closing(elem, parent_kind, out, styles, ctx);
722 return;
789 return;
723 }
790 }
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+ }
724 if elem.kind == ElementKind::Children {
869 if elem.kind == ElementKind::Children {
725 return;
870 return;
726 }
871 }
@@ -803,9 +948,13 @@ fn generate_element(
803 ElementKind::Carousel => "div",
948 ElementKind::Carousel => "div",
804 ElementKind::Chip => "span",
949 ElementKind::Chip => "span",
805 ElementKind::Tag => "span",
950 ElementKind::Tag => "span",
951+ ElementKind::Noscript => "noscript",
952+ ElementKind::Address => "address",
953+ ElementKind::Search => "search",
806 ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule
954 ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule
807 | ElementKind::Children | ElementKind::Slot(_) | ElementKind::Fragment
955 | ElementKind::Children | ElementKind::Slot(_) | ElementKind::Fragment
808− | ElementKind::Source | ElementKind::Spacer => unreachable!(),
956+ | ElementKind::Source | ElementKind::Spacer
957+ | ElementKind::Script | ElementKind::Breadcrumb => unreachable!(),
809 };
958 };
810
959
811 let kind_label = match elem.kind {
960 let kind_label = match elem.kind {
@@ -870,6 +1019,9 @@ fn generate_element(
870 ElementKind::Carousel => "carousel",
1019 ElementKind::Carousel => "carousel",
871 ElementKind::Chip => "chip",
1020 ElementKind::Chip => "chip",
872 ElementKind::Tag => "tag",
1021 ElementKind::Tag => "tag",
1022+ ElementKind::Noscript => "noscript",
1023+ ElementKind::Address => "address",
1024+ ElementKind::Search => "search",
873 _ => "",
1025 _ => "",
874 };
1026 };
875
1027
@@ -1270,6 +1422,11 @@ const PSEUDO_PREFIXES: &[(&str, &str)] = &[
1270 ("before:", "::before"),
1422 ("before:", "::before"),
1271 ("after:", "::after"),
1423 ("after:", "::after"),
1272 ("selection:", "::selection"),
1424 ("selection:", "::selection"),
1425+ ("visited:", ":visited"),
1426+ ("empty:", ":empty"),
1427+ ("target:", ":target"),
1428+ ("valid:", ":valid"),
1429+ ("invalid:", ":invalid"),
1273];
1430];
1274const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:", "2xl:"];
1431const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:", "2xl:"];
1275const MEDIA_PREFIXES: &[&str] = &["dark:", "print:", "motion-safe:", "motion-reduce:", "landscape:", "portrait:"];
1432const MEDIA_PREFIXES: &[&str] = &["dark:", "print:", "motion-safe:", "motion-reduce:", "landscape:", "portrait:"];
@@ -1961,6 +2118,23 @@ fn attrs_to_css(
1961 push_css(&mut css, "text-decoration-style", v);
2118 push_css(&mut css, "text-decoration-style", v);
1962 }
2119 }
1963 }
2120 }
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+ }
1964
2138
1965 // Place items/self
2139 // Place items/self
1966 "place-items" => {
2140 "place-items" => {
src/convert.rs+3
@@ -890,6 +890,9 @@ fn emit_element(
890 "iframe" => "@iframe",
890 "iframe" => "@iframe",
891 "output" => "@output",
891 "output" => "@output",
892 "canvas" => "@canvas",
892 "canvas" => "@canvas",
893+ "noscript" => "@noscript",
894+ "address" => "@address",
895+ "search" => "@search",
893 // Unknown / raw elements
896 // Unknown / raw elements
894 "script" | "style" | "svg" | "object" | "embed" => {
897 "script" | "style" | "svg" | "object" | "embed" => {
895 emit_raw_element(tag, attrs, children, depth, out);
898 emit_raw_element(tag, attrs, children, depth, out);
src/fmt.rs+7−2
@@ -27,13 +27,18 @@ fn attr_category(key: &str) -> u8 {
27 .or_else(|| key.strip_prefix("motion-reduce:"))
27 .or_else(|| key.strip_prefix("motion-reduce:"))
28 .or_else(|| key.strip_prefix("landscape:"))
28 .or_else(|| key.strip_prefix("landscape:"))
29 .or_else(|| key.strip_prefix("portrait:"))
29 .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:"))
30 .unwrap_or(key);
35 .unwrap_or(key);
31
36
32 match base {
37 match base {
33 // Layout (parent)
38 // Layout (parent)
34 "spacing" | "gap" | "gap-x" | "gap-y" | "wrap"
39 "spacing" | "gap" | "gap-x" | "gap-y" | "wrap"
35 | "grid" | "grid-cols" | "grid-rows"
40 | "grid" | "grid-cols" | "grid-rows"
36− | "column-count" | "column-gap" => 0,
41+ | "column-count" | "column-gap" | "column-width" | "column-rule" => 0,
37 // Sizing
42 // Sizing
38 "width" | "height" | "min-width" | "max-width" | "min-height" | "max-height"
43 "width" | "height" | "min-width" | "max-width" | "min-height" | "max-height"
39 | "flex-grow" | "flex-shrink" | "flex-basis" => 1,
44 | "flex-grow" | "flex-shrink" | "flex-basis" => 1,
@@ -61,7 +66,7 @@ fn attr_category(key: &str) -> u8 {
61 | "text-align" | "line-height" | "letter-spacing" | "text-transform"
66 | "text-align" | "line-height" | "letter-spacing" | "text-transform"
62 | "white-space" | "text-overflow" | "word-break" | "overflow-wrap"
67 | "white-space" | "text-overflow" | "word-break" | "overflow-wrap"
63 | "text-decoration" | "text-decoration-color" | "text-decoration-thickness"
68 | "text-decoration" | "text-decoration-color" | "text-decoration-thickness"
64− | "text-decoration-style" | "list-style"
69+ | "text-decoration-style" | "text-underline-offset" | "list-style"
65 | "text-indent" | "hyphens" | "writing-mode" => 7,
70 | "text-indent" | "hyphens" | "writing-mode" => 7,
66 // Effects & interaction
71 // Effects & interaction
67 "transform" | "transition" | "animation" | "cursor" | "backdrop-filter"
72 "transform" | "transition" | "animation" | "cursor" | "backdrop-filter"
src/main.rs+94
@@ -221,6 +221,8 @@ Commands:
221 dead-code <dir> Find unused @fn, @define, @let across project
221 dead-code <dir> Find unused @fn, @define, @let across project
222 deploy <dir> Build and deploy to GitHub Pages
222 deploy <dir> Build and deploy to GitHub Pages
223 playground [out.html] Generate a self-contained HTML playground
223 playground [out.html] Generate a self-contained HTML playground
224+ clean [dir] Remove generated .html files
225+ outline <file.hl> Show document structure tree
224
226
225Options:
227Options:
226 -w, --watch Watch for changes and recompile
228 -w, --watch Watch for changes and recompile
@@ -1712,6 +1714,98 @@ document.getElementById('editor').addEventListener('keydown',e=>{if(e.key==='Ent
1712 return;
1714 return;
1713 }
1715 }
1714
1716
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+
1715 // Handle "convert" subcommand
1809 // Handle "convert" subcommand
1716 if args.len() >= 2 && args[1] == "convert" {
1810 if args.len() >= 2 && args[1] == "convert" {
1717 if args.len() < 3 {
1811 if args.len() < 3 {
src/parser.rs+89
@@ -100,6 +100,14 @@ struct ParseContext {
100 deprecated_fns: HashMap<String, String>,
100 deprecated_fns: HashMap<String, String>,
101 /// Theme tokens: (name, value) pairs from @theme
101 /// Theme tokens: (name, value) pairs from @theme
102 theme_tokens: Vec<(String, String)>,
102 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>,
103}
111}
104
112
105struct Parser {
113struct Parser {
@@ -142,6 +150,10 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
142 define_lines: HashMap::new(),
150 define_lines: HashMap::new(),
143 deprecated_fns: HashMap::new(),
151 deprecated_fns: HashMap::new(),
144 theme_tokens: Vec::new(),
152 theme_tokens: Vec::new(),
153+ canonical: None,
154+ base_url: None,
155+ font_faces: Vec::new(),
156+ json_ld_blocks: Vec::new(),
145 };
157 };
146 let nodes = parser.parse_children(0, &mut ctx);
158 let nodes = parser.parse_children(0, &mut ctx);
147 validate_tree(&nodes, None, &mut ctx.diagnostics);
159 validate_tree(&nodes, None, &mut ctx.diagnostics);
@@ -161,6 +173,10 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
161 og_tags: ctx.og_tags,
173 og_tags: ctx.og_tags,
162 custom_breakpoints: ctx.custom_breakpoints,
174 custom_breakpoints: ctx.custom_breakpoints,
163 theme_tokens: ctx.theme_tokens,
175 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,
164 nodes,
180 nodes,
165 },
181 },
166 diagnostics: ctx.diagnostics,
182 diagnostics: ctx.diagnostics,
@@ -1093,6 +1109,57 @@ impl Parser {
1093 return Ok(None);
1109 return Ok(None);
1094 }
1110 }
1095
1111
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+
1096 // --- @deprecated annotation ---
1163 // --- @deprecated annotation ---
1097
1164
1098 if let Some(rest) = content.strip_prefix("@deprecated ") {
1165 if let Some(rest) = content.strip_prefix("@deprecated ") {
@@ -1540,6 +1607,7 @@ const KNOWN_ELEMENTS: &[&str] = &[
1540 "iframe", "output", "canvas",
1607 "iframe", "output", "canvas",
1541 "grid", "stack", "spacer", "badge", "tooltip",
1608 "grid", "stack", "spacer", "badge", "tooltip",
1542 "avatar", "carousel", "chip", "tag",
1609 "avatar", "carousel", "chip", "tag",
1610+ "script", "noscript", "address", "search", "breadcrumb",
1543];
1611];
1544
1612
1545const KNOWN_DIRECTIVES: &[&str] = &[
1613const KNOWN_DIRECTIVES: &[&str] = &[
@@ -1548,6 +1616,7 @@ const KNOWN_DIRECTIVES: &[&str] = &[
1548 "match", "case", "default", "warn", "debug",
1616 "match", "case", "default", "warn", "debug",
1549 "unless", "og", "breakpoint", "lang", "favicon",
1617 "unless", "og", "breakpoint", "lang", "favicon",
1550 "use", "theme", "deprecated", "extends",
1618 "use", "theme", "deprecated", "extends",
1619+ "canonical", "base", "font-face", "json-ld",
1551];
1620];
1552
1621
1553fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
1622fn 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
1623 "carousel" => Ok(ElementKind::Carousel),
1692 "carousel" => Ok(ElementKind::Carousel),
1624 "chip" => Ok(ElementKind::Chip),
1693 "chip" => Ok(ElementKind::Chip),
1625 "tag" => Ok(ElementKind::Tag),
1694 "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),
1626 _ => {
1700 _ => {
1627 let all_known: Vec<&str> = KNOWN_ELEMENTS
1701 let all_known: Vec<&str> = KNOWN_ELEMENTS
1628 .iter()
1702 .iter()
@@ -1756,6 +1830,8 @@ const KNOWN_ATTRS: &[&str] = &[
1756 "border-collapse", "border-spacing",
1830 "border-collapse", "border-spacing",
1757 // Text decoration variants
1831 // Text decoration variants
1758 "text-decoration", "text-decoration-color", "text-decoration-thickness", "text-decoration-style",
1832 "text-decoration", "text-decoration-color", "text-decoration-thickness", "text-decoration-style",
1833+ "text-underline-offset",
1834+ "column-width", "column-rule",
1759 // Grid/flex placement
1835 // Grid/flex placement
1760 "place-items", "place-self",
1836 "place-items", "place-self",
1761 // Scroll behavior
1837 // Scroll behavior
@@ -1775,6 +1851,8 @@ const KNOWN_ATTRS: &[&str] = &[
1775 // Iframe/output attrs
1851 // Iframe/output attrs
1776 "sandbox", "allow", "allowfullscreen", "referrerpolicy",
1852 "sandbox", "allow", "allowfullscreen", "referrerpolicy",
1777 "formaction", "formmethod", "formtarget", "target",
1853 "formaction", "formmethod", "formtarget", "target",
1854+ // Script attributes
1855+ "defer", "async", "crossorigin", "integrity", "nomodule",
1778 // Pseudo-element content
1856 // Pseudo-element content
1779 "content",
1857 "content",
1780 // CSS shorthands
1858 // CSS shorthands
@@ -2279,6 +2357,11 @@ fn strip_all_prefixes(key: &str) -> &str {
2279 .or_else(|| key.strip_prefix("selection:"))
2357 .or_else(|| key.strip_prefix("selection:"))
2280 .or_else(|| key.strip_prefix("before:"))
2358 .or_else(|| key.strip_prefix("before:"))
2281 .or_else(|| key.strip_prefix("after:"))
2359 .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:"))
2282 .or_else(|| {
2365 .or_else(|| {
2283 // Handle nth:EXPR: prefix (e.g., nth:3:background -> background)
2366 // Handle nth:EXPR: prefix (e.g., nth:3:background -> background)
2284 if key.starts_with("nth:") {
2367 if key.starts_with("nth:") {
@@ -2406,6 +2489,11 @@ fn element_kind_name(kind: &ElementKind) -> &'static str {
2406 ElementKind::Carousel => "@carousel",
2489 ElementKind::Carousel => "@carousel",
2407 ElementKind::Chip => "@chip",
2490 ElementKind::Chip => "@chip",
2408 ElementKind::Tag => "@tag",
2491 ElementKind::Tag => "@tag",
2492+ ElementKind::Script => "@script",
2493+ ElementKind::Noscript => "@noscript",
2494+ ElementKind::Address => "@address",
2495+ ElementKind::Search => "@search",
2496+ ElementKind::Breadcrumb => "@breadcrumb",
2409 }
2497 }
2410}
2498}
2411
2499
@@ -2421,6 +2509,7 @@ fn is_container(kind: &ElementKind) -> bool {
2421 | ElementKind::DefinitionDescription | ElementKind::Fieldset | ElementKind::Datalist
2509 | ElementKind::DefinitionDescription | ElementKind::Fieldset | ElementKind::Datalist
2422 | ElementKind::Iframe | ElementKind::Canvas
2510 | ElementKind::Iframe | ElementKind::Canvas
2423 | ElementKind::Grid | ElementKind::Stack | ElementKind::Carousel
2511 | ElementKind::Grid | ElementKind::Stack | ElementKind::Carousel
2512+ | ElementKind::Noscript | ElementKind::Address | ElementKind::Search | ElementKind::Breadcrumb
2424 )
2513 )
2425}
2514}
2426
2515
tests/snapshots.rs+169
@@ -2715,3 +2715,172 @@ fn no_warning_new_attrs_batch6() {
2715 diags
2715 diags
2716 );
2716 );
2717}
2717}
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+ });