on master help sign in
Trim the CLI to its core commands
b07ee65iko
src/cli.rs+38−273
@@ -1,85 +1,56 @@
1use std::env;
1use std::env;
2use std::process;
2use std::process;
3
3
4+/// Subcommands understood by the CLI.
5+pub const COMMANDS: &[&str] = &[
6+ "build", "serve", "watch", "check", "lint", "fmt", "convert", "upgrade", "lsp",
7+];
8+
4pub fn print_help() {
9pub fn print_help() {
5 eprintln!(
10 eprintln!(
6 "\
11 "\
7htmlang {} - a minimalist layout language that compiles to static HTML
12htmlang {} - a minimalist layout language that compiles to static HTML
8
13
9Usage: htmlang [options] <file.hl | directory>
14Usage: htmlang [options] <file.hl | directory>
15+ htmlang <command> [args]
10
16
11Commands:
17Commands:
12− init [dir] [--template blog|docs|portfolio]
13− Create a new project (defaults to current directory)
14− new <page-name> Create a new .hl page from a template
15− test [dir|file] Run @assert directives across a project
16− build <dir> [-o <out>] [--minify] Compile all .hl files (parallel)
18+ build <dir> [-o <out>] [--minify] [--compat] [--strict] [--shared-css]
19+ Compile every .hl file under a directory
20+ serve [dir|file] [-p N] [--open] [--https --cert <pem> --key <pem>]
17− serve [dir|file] [-p N] [--open] Start dev server with hot reload
21+ Dev server with live reload
18− watch [dir|file] [-o out] Watch for changes and recompile
22+ watch [dir|file] [-o <out>]
23+ Recompile on change, without a server
24+ check <file.hl | dir> [--format json]
19− check <file.hl | dir> Check for errors without writing output
25+ Report diagnostics without writing output
20− convert <file.html> Convert an HTML file to .hl format (stdout)
26+ lint <file.hl | dir> [--format json]
21− fmt <file.hl> Format a .hl file (normalizes indentation)
22− sitemap <dir> Generate sitemap.xml from .hl files
23− lint <file.hl | dir> Stricter lint checks (accessibility, nesting, etc.)
27+ Stricter checks (accessibility, nesting)
24− stats <file.hl | dir> Show file statistics (elements, CSS rules, colors)
28+ fmt <file.hl> Format a file in place
25− preview <file.hl> Compile and open in browser (one-shot, no server)
29+ convert <file.html> Convert HTML to .hl (printed to stdout)
26− diff <a.hl> <b.hl> Show differences between two .hl files
27− export <dir> [-o f] Compile and bundle into an archive
28− repl Interactive REPL (type .hl, get HTML)
29− feed <dir> [-b URL] Generate RSS feed from @page metadata
30− components <dir> List all component definitions across a project
31− deps <dir> Show file dependency graph (@include/@import)
32− dead-code <dir> Find unused @let definitions across project
33− deploy <dir> [--provider github-pages|netlify|vercel|cloudflare]
34− Build and deploy
35− playground [out.html] Generate a self-contained HTML playground
36− clean [dir] Remove generated .html files
37− upgrade [dir|file] Auto-upgrade syntax to latest conventions
30+ upgrade [dir|file] Rewrite removed or renamed syntax
38− create-component <name> [params...] Scaffold a new component file
39− outline <file.hl> Show document structure tree
40− doctor Check toolchain health
41− bundle <file|dir> [-o <out>] Compile and inline all assets as data URIs
42− size <file|dir> Report output sizes (raw, minified, ~gzip)
43− benchmark <file|dir> Measure compile time and output size
44− explain <file.hl> Show what CSS each source line produces
45− lsp Start the Language Server Protocol server
31+ lsp Start the language server (stdio)
46− completions <shell> Generate shell completions (bash, zsh, fish)
47
32
48Options:
33Options:
49− -w, --watch Watch for changes and recompile
34+ -o, --output <path> Output file or directory
35+ -w, --watch Recompile on change
50− -s, --serve Start dev server with hot reload (implies --watch)
36+ -s, --serve Dev server with live reload (implies --watch)
51− -p, --port <N> Port for dev server (default: 3000)
37+ -p, --port <N> Dev server port (default: 3000)
52− --open Open browser automatically (with --serve)
38+ --open Open the browser (with --serve)
53− -o, --output <path> Output file/directory path
54− -d, --dev Development mode
39+ -d, --dev Development mode (readable output, source maps)
55− -c, --check Check for errors without writing output
40+ -c, --check Check for errors without writing output
56− --compat Add vendor prefixes for broader browser support
41+ --compat Add vendor prefixes
57− --strict Treat warnings as errors (useful for CI)
42+ --strict Treat warnings as errors
58− --partial Output HTML fragment without document wrapper
43+ --partial Output an HTML fragment without the document wrapper
59− --format json Output diagnostics as JSON to stdout
44+ --format json Print diagnostics as JSON
60− -h, --help Show this help
45+ -h, --help Show this help
61− -V, --version Show version
46+ -V, --version Show version
62
47
63Examples:
48Examples:
64− htmlang init Scaffold a new project
65− htmlang init my-site Scaffold in a new directory
66− htmlang new about-us Create about-us.hl from template
67− htmlang page.hl Compile page.hl to page.html
49+ htmlang page.hl Compile page.hl to page.html
68− htmlang site/ Compile all .hl files in directory
50+ htmlang site/ Compile every .hl file in a directory
69− htmlang -w page.hl Recompile on file changes
70− htmlang -s page.hl Start dev server with hot reload
71− htmlang -s --open site/ Serve and open browser
51+ htmlang -s --open site/ Serve a site and open the browser
72− htmlang -s site/ Serve a multi-page site
73− htmlang -s -p 8080 page.hl
74− htmlang -c page.hl Lint without writing output
75− htmlang check src/ Check all files in a directory
76− htmlang convert page.html Convert HTML to .hl format
77− htmlang --format json page.hl Get diagnostics as JSON
78− htmlang fmt page.hl Format a file
79− htmlang build src/ -o dist/ Compile all .hl files to dist/
52+ htmlang build src/ -o dist/ Compile src/ into dist/
80− htmlang sitemap src/ Generate sitemap.xml
53+ htmlang check src/ Check every file in a directory",
81− htmlang lint src/ Lint all files in directory
82− htmlang stats page.hl Show file statistics",
83 env!("CARGO_PKG_VERSION")
54 env!("CARGO_PKG_VERSION")
84 );
55 );
85}
56}
@@ -123,209 +94,3 @@ pub fn run_lsp() {
123 }
94 }
124 }
95 }
125}
96}
126−
127−pub fn print_shell_completions(shell: &str) {
128− match shell {
129− "bash" => print_bash_completions(),
130− "zsh" => print_zsh_completions(),
131− "fish" => print_fish_completions(),
132− _ => {
133− eprintln!("unknown shell: {}. Supported: bash, zsh, fish", shell);
134− process::exit(1);
135− }
136− }
137−}
138−
139−fn print_bash_completions() {
140− println!(
141− r#"_htmlang() {{
142− local cur prev commands
143− cur="${{COMP_WORDS[COMP_CWORD]}}"
144− prev="${{COMP_WORDS[COMP_CWORD-1]}}"
145− commands="init new build serve watch check convert fmt sitemap lint stats preview diff export repl feed components deps dead-code deploy playground clean upgrade create-component outline doctor bundle size benchmark explain test lsp completions"
146−
147− if [ "$COMP_CWORD" -eq 1 ]; then
148− COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
149− COMPREPLY+=( $(compgen -f -X '!*.hl' -- "$cur") )
150− COMPREPLY+=( $(compgen -d -- "$cur") )
151− return
152− fi
153−
154− case "$prev" in
155− -o|--output) COMPREPLY=( $(compgen -f -- "$cur") ) ;;
156− -p|--port) COMPREPLY=() ;;
157− --format) COMPREPLY=( $(compgen -W "json" -- "$cur") ) ;;
158− --template|-t) COMPREPLY=( $(compgen -W "blog docs portfolio" -- "$cur") ) ;;
159− --provider) COMPREPLY=( $(compgen -W "github-pages netlify vercel cloudflare" -- "$cur") ) ;;
160− completions) COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") ) ;;
161− *)
162− COMPREPLY=( $(compgen -W "-w --watch -s --serve -d --dev -c --check --compat --strict --partial --open --format --minify -o --output -p --port -h --help -V --version" -- "$cur") )
163− COMPREPLY+=( $(compgen -f -X '!*.hl' -- "$cur") )
164− COMPREPLY+=( $(compgen -d -- "$cur") )
165− ;;
166− esac
167−}}
168−complete -F _htmlang htmlang"#
169− );
170−}
171−
172−fn print_zsh_completions() {
173− println!(
174− r#"#compdef htmlang
175−
176−_htmlang() {{
177− local -a commands=(
178− 'init:Create a new project'
179− 'new:Create a new .hl page'
180− 'build:Compile all .hl files'
181− 'serve:Start dev server'
182− 'watch:Watch and recompile'
183− 'check:Check for errors'
184− 'convert:Convert HTML to .hl'
185− 'fmt:Format a .hl file'
186− 'sitemap:Generate sitemap.xml'
187− 'lint:Lint checks'
188− 'stats:Show file statistics'
189− 'preview:Compile and open in browser'
190− 'diff:Compare two .hl files'
191− 'export:Bundle into archive'
192− 'repl:Interactive REPL'
193− 'feed:Generate RSS feed'
194− 'components:List component definitions'
195− 'deps:Show dependency graph'
196− 'dead-code:Find unused definitions'
197− 'deploy:Build and deploy'
198− 'playground:Generate playground'
199− 'clean:Remove generated files'
200− 'upgrade:Auto-upgrade syntax'
201− 'create-component:Scaffold component'
202− 'outline:Show document structure'
203− 'doctor:Check toolchain health'
204− 'bundle:Inline all assets'
205− 'size:Report output sizes'
206− 'benchmark:Measure compile time'
207− 'explain:Show CSS mapping per source line'
208− 'test:Run assertions'
209− 'lsp:Start LSP server'
210− 'completions:Generate shell completions'
211− )
212−
213− local -a flags=(
214− '-w[Watch for changes]'
215− '--watch[Watch for changes]'
216− '-s[Start dev server]'
217− '--serve[Start dev server]'
218− '-d[Development mode]'
219− '--dev[Development mode]'
220− '-c[Check only]'
221− '--check[Check only]'
222− '--compat[Vendor prefixes]'
223− '--strict[Warnings as errors]'
224− '--partial[Output HTML fragment]'
225− '--open[Open browser]'
226− '--minify[Minify output]'
227− '-o[Output path]:path:_files'
228− '--output[Output path]:path:_files'
229− '-p[Port]:port:'
230− '--port[Port]:port:'
231− '--format[Output format]:format:(json)'
232− '-h[Show help]'
233− '--help[Show help]'
234− '-V[Show version]'
235− '--version[Show version]'
236− )
237−
238− if (( CURRENT == 2 )); then
239− _alternative 'commands:command:compadd -a commands' 'files:file:_files -g "*.hl"' 'dirs:directory:_directories'
240− else
241− _alternative 'flags:flag:compadd -a flags' 'files:file:_files -g "*.hl"' 'dirs:directory:_directories'
242− fi
243−}}
244−
245−_htmlang "$@""#
246− );
247−}
248−
249−fn print_fish_completions() {
250− let commands = [
251− ("init", "Create a new project"),
252− ("new", "Create a new .hl page"),
253− ("build", "Compile all .hl files"),
254− ("serve", "Start dev server with hot reload"),
255− ("watch", "Watch for changes and recompile"),
256− ("check", "Check for errors"),
257− ("convert", "Convert HTML to .hl"),
258− ("fmt", "Format a .hl file"),
259− ("sitemap", "Generate sitemap.xml"),
260− ("lint", "Strict lint checks"),
261− ("stats", "Show file statistics"),
262− ("preview", "Compile and open in browser"),
263− ("diff", "Compare two .hl files"),
264− ("export", "Bundle into archive"),
265− ("repl", "Interactive REPL"),
266− ("feed", "Generate RSS feed"),
267− ("components", "List component definitions"),
268− ("deps", "Show dependency graph"),
269− ("dead-code", "Find unused definitions"),
270− ("deploy", "Build and deploy"),
271− ("playground", "Generate playground"),
272− ("clean", "Remove generated files"),
273− ("upgrade", "Auto-upgrade syntax"),
274− ("create-component", "Scaffold component"),
275− ("outline", "Show document structure"),
276− ("doctor", "Check toolchain health"),
277− ("bundle", "Inline all assets as data URIs"),
278− ("size", "Report output sizes"),
279− ("benchmark", "Measure compile time"),
280− ("explain", "Show CSS mapping per source line"),
281− ("test", "Run assertions"),
282− ("lsp", "Start LSP server"),
283− ("completions", "Generate shell completions"),
284− ];
285−
286− for (cmd, desc) in &commands {
287− println!(
288− "complete -c htmlang -n '__fish_use_subcommand' -a '{}' -d '{}'",
289− cmd, desc
290− );
291− }
292−
293− let flags = [
294− ("-w", "--watch", "Watch for changes"),
295− ("-s", "--serve", "Start dev server"),
296− ("-d", "--dev", "Development mode"),
297− ("-c", "--check", "Check only"),
298− ("", "--compat", "Add vendor prefixes"),
299− ("", "--strict", "Treat warnings as errors"),
300− (
301− "",
302− "--partial",
303− "Output HTML fragment without document wrapper",
304− ),
305− ("", "--open", "Open browser"),
306− ("", "--minify", "Minify output"),
307− ("-o", "--output", "Output path"),
308− ("-p", "--port", "Dev server port"),
309− ("-h", "--help", "Show help"),
310− ("-V", "--version", "Show version"),
311− ];
312−
313− for (short, long, desc) in &flags {
314− if short.is_empty() {
315− println!(
316− "complete -c htmlang -l '{}' -d '{}'",
317− long.trim_start_matches('-'),
318− desc
319− );
320− } else {
321− println!(
322− "complete -c htmlang -s '{}' -l '{}' -d '{}'",
323− short.trim_start_matches('-'),
324− long.trim_start_matches('-'),
325− desc
326− );
327− }
328− }
329−
330− println!("complete -c htmlang -n '__fish_seen_subcommand_from completions' -a 'bash zsh fish'");
331−}
src/main.rs+74−2183
@@ -159,13 +159,6 @@ fn json_escape_string(s: &str) -> String {
159}
159}
160
160
161
161
162−fn xml_escape(s: &str) -> String {
163− s.replace('&', "&amp;")
164− .replace('<', "&lt;")
165− .replace('>', "&gt;")
166− .replace('"', "&quot;")
167−}
168−
169fn json_array(items: impl Iterator<Item = String>) -> String {
162fn json_array(items: impl Iterator<Item = String>) -> String {
170 let inner: Vec<String> = items.collect();
163 let inner: Vec<String> = items.collect();
171 format!("[{}]", inner.join(","))
164 format!("[{}]", inner.join(","))
@@ -191,23 +184,6 @@ fn print_json_diagnostics(diagnostics: &[DiagnosticJson]) {
191 println!("{}", json_object(&[("diagnostics", arr)]));
184 println!("{}", json_object(&[("diagnostics", arr)]));
192}
185}
193
186
194−fn kebab_to_title(s: &str) -> String {
195− s.split('-')
196− .map(|word| {
197− let mut chars = word.chars();
198− match chars.next() {
199− Some(c) => {
200− let mut title = c.to_uppercase().to_string();
201− title.extend(chars);
202− title
203− }
204− None => String::new(),
205− }
206− })
207− .collect::<Vec<_>>()
208− .join(" ")
209−}
210−
211fn format_bytes(bytes: usize) -> String {
187fn format_bytes(bytes: usize) -> String {
212 if bytes >= 1_048_576 {
188 if bytes >= 1_048_576 {
213 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
189 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
@@ -218,97 +194,6 @@ fn format_bytes(bytes: usize) -> String {
218 }
194 }
219}
195}
220
196
221−fn bundle_assets(html: &str, base_dir: &Path) -> String {
222− use std::io::Read;
223− let mut result = html.to_string();
224−
225− // Inline images referenced as src="..." in <img> tags
226− let img_re_patterns = ["src=\"", "url("];
227− for pattern in &img_re_patterns {
228− let mut output = String::new();
229− let mut remaining = result.as_str();
230− while let Some(pos) = remaining.find(pattern) {
231− output.push_str(&remaining[..pos + pattern.len()]);
232− remaining = &remaining[pos + pattern.len()..];
233−
234− // Find the closing delimiter
235− let close_char = if *pattern == "src=\"" { '"' } else { ')' };
236− if let Some(end) = remaining.find(close_char) {
237− let path_str = remaining[..end].trim_matches(|c| c == '\'' || c == '"');
238−
239− // Skip data URIs, remote URLs, and anchors
240− if !path_str.starts_with("data:")
241− && !path_str.starts_with("http://")
242− && !path_str.starts_with("https://")
243− && !path_str.starts_with('#')
244− && !path_str.is_empty()
245− {
246− let asset_path = base_dir.join(path_str);
247− if asset_path.exists()
248− && let Ok(mut file) = std::fs::File::open(&asset_path)
249− {
250− let mut buf = Vec::new();
251− if file.read_to_end(&mut buf).is_ok() {
252− let mime = match asset_path.extension().and_then(|e| e.to_str()) {
253− Some("png") => "image/png",
254− Some("jpg") | Some("jpeg") => "image/jpeg",
255− Some("gif") => "image/gif",
256− Some("svg") => "image/svg+xml",
257− Some("webp") => "image/webp",
258− Some("ico") => "image/x-icon",
259− Some("woff2") => "font/woff2",
260− Some("woff") => "font/woff",
261− Some("ttf") => "font/ttf",
262− Some("otf") => "font/otf",
263− Some("avif") => "image/avif",
264− _ => "application/octet-stream",
265− };
266− use std::fmt::Write as FmtWrite;
267− let mut b64 = String::new();
268− // Simple base64 encoding
269− let encoded = base64_encode(&buf);
270− let _ = write!(b64, "data:{};base64,{}", mime, encoded);
271− output.push_str(&b64);
272− remaining = &remaining[end..];
273− continue;
274− }
275− }
276− }
277− // If we couldn't inline, keep original path
278− output.push_str(&remaining[..end]);
279− remaining = &remaining[end..];
280− }
281− }
282− output.push_str(remaining);
283− result = output;
284− }
285− result
286−}
287−
288−fn base64_encode(data: &[u8]) -> String {
289− const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
290− let mut result = String::with_capacity(data.len().div_ceil(3) * 4);
291− for chunk in data.chunks(3) {
292− let b0 = chunk[0] as u32;
293− let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
294− let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
295− let n = (b0 << 16) | (b1 << 8) | b2;
296− result.push(CHARS[((n >> 18) & 63) as usize] as char);
297− result.push(CHARS[((n >> 12) & 63) as usize] as char);
298− if chunk.len() > 1 {
299− result.push(CHARS[((n >> 6) & 63) as usize] as char);
300− } else {
301− result.push('=');
302− }
303− if chunk.len() > 2 {
304− result.push(CHARS[(n & 63) as usize] as char);
305− } else {
306− result.push('=');
307− }
308− }
309− result
310−}
311−
312fn copy_non_hl_files(src_dir: &Path, out_dir: &Path) {
197fn copy_non_hl_files(src_dir: &Path, out_dir: &Path) {
313 let skip = out_dir.canonicalize().ok();
198 let skip = out_dir.canonicalize().ok();
314 // Building in place: the assets are already where they belong, and
199 // Building in place: the assets are already where they belong, and
@@ -427,142 +312,6 @@ h1{{color:#ff6b6b;margin-bottom:1rem;font-size:1.5rem}}
427 )
312 )
428}
313}
429
314
430−fn init_project(dir: &str, template_name: Option<&str>) {
431− let dir = Path::new(dir);
432− if dir.to_str() != Some(".")
433− && let Err(e) = fs::create_dir_all(dir)
434− {
435− eprintln!("error: cannot create directory '{}': {}", dir.display(), e);
436− process::exit(1);
437− }
438−
439− let index_path = dir.join("index.hl");
440− if index_path.exists() {
441− eprintln!("error: {} already exists", index_path.display());
442− process::exit(1);
443− }
444−
445− let template = match template_name {
446− Some("blog") => {
447− r#"@page My Blog
448−@let primary #3b82f6
449−@let bg-dark #1a1a2e
450−
451−@column [max-width 720, center-x, padding 40, spacing 30]
452− @header [spacing 10]
453− @text [bold, size 36] My Blog
454− @paragraph [color #666, line-height 1.6] Thoughts and ideas.
455−
456− @main [spacing 40]
457− @article [spacing 12, padding-bottom 30, border-bottom 1 #eee]
458− @text [bold, size 24] First Post
459− @text [color #888, size 14] 2024-01-15
460− @paragraph [line-height 1.8]
461− Welcome to my blog. This is the first post.
462−
463− @article [spacing 12, padding-bottom 30, border-bottom 1 #eee]
464− @text [bold, size 24] Another Post
465− @text [color #888, size 14] 2024-01-10
466− @paragraph [line-height 1.8]
467− Here is another post with more content.
468−
469− @footer [padding-top 20, border-top 1 #eee]
470− @text [color #888, size 14, text-align center] Built with htmlang
471−"#
472− }
473− Some("docs") => {
474− r#"@page Documentation
475−@let primary #3b82f6
476−@let sidebar-width 250
477−
478−@row [min-height 100vh]
479− @aside [width $sidebar-width, padding 20, background #f8f9fa, border-right 1 #e0e0e0, spacing 8]
480− @text [bold, size 18, padding-bottom 10] Docs
481− @nav [spacing 4]
482− @link # Getting Started
483− @link # Installation
484− @link # Configuration
485− @link # API Reference
486−
487− @main [width fill, padding 40, max-width 800, spacing 20]
488− @text [bold, size 32] Getting Started
489− @paragraph [line-height 1.8]
490− Welcome to the documentation. Use the sidebar to navigate.
491−
492− @text [bold, size 24] Installation
493− @code [padding 16, background #f5f5f5, rounded 8]
494− npm install my-package
495−
496− @text [bold, size 24] Usage
497− @paragraph [line-height 1.8]
498− Import and use the library in your project.
499−"#
500− }
501− Some("portfolio") => {
502− r#"@page Portfolio
503−@let primary #3b82f6
504−@let accent #8b5cf6
505−
506−@column [min-height 100vh, spacing 0]
507− @header [padding 20 40, background white, border-bottom 1 #eee]
508− @row [max-width 1200, center-x, width fill, justify-content space-between, align-items center]
509− @text [bold, size 20] Jane Doe
510− @nav
511− @row [spacing 20]
512− @link # Work
513− @link # About
514− @link # Contact
515−
516− @main [max-width 1200, center-x, padding 60 40, spacing 60]
517− @column [spacing 10, center-x, text-align center, max-width 600]
518− @text [bold, size 48] Designer & Developer
519− @paragraph [color #666, size 18, line-height 1.6]
520− I create beautiful, functional digital experiences.
521−
522− @grid [grid-cols 3, gap 20]
523− @el [aspect-ratio 1, background #f0f0f0, rounded 12, padding 20, spacing 10]
524− @text [bold] Project One
525− @text [color #666, size 14] Web Design
526−
527− @el [aspect-ratio 1, background #f0f0f0, rounded 12, padding 20, spacing 10]
528− @text [bold] Project Two
529− @text [color #666, size 14] Branding
530−
531− @el [aspect-ratio 1, background #f0f0f0, rounded 12, padding 20, spacing 10]
532− @text [bold] Project Three
533− @text [color #666, size 14] Development
534−
535− @footer [padding 30 40, background #1a1a2e, color white, text-align center]
536− @text [size 14] Built with htmlang
537−"#
538− }
539− _ => {
540− r#"@page My Site
541−@let primary #3b82f6
542−
543−@column [max-width 800, center-x, padding 40, spacing 20]
544− @text [bold, size 32] Hello, htmlang!
545−
546− @paragraph [line-height 1.6]
547− Edit {@text [bold, color $primary] index.hl} and run
548− {@text [font monospace, size 14] htmlang -s .} to get started.
549−
550− @row [spacing 10]
551− @el [padding 12 24, background $primary, rounded 8, cursor pointer, hover:background #2563eb, transition all 0.15s ease] > @link https://github.com/nicholasgasior/htmlang
552− @text [color white, bold] Documentation
553−"#
554− }
555− };
556−
557− match fs::write(&index_path, template) {
558− Ok(()) => eprintln!("created {}", index_path.display()),
559− Err(e) => {
560− eprintln!("error: {}: {}", index_path.display(), e);
561− process::exit(1);
562− }
563− }
564−}
565−
566fn collect_hl_files_recursive(dir: &Path) -> Vec<PathBuf> {
315fn collect_hl_files_recursive(dir: &Path) -> Vec<PathBuf> {
567 let mut files = Vec::new();
316 let mut files = Vec::new();
568 collect_hl_recursive_inner(dir, &mut files);
317 collect_hl_recursive_inner(dir, &mut files);
@@ -590,106 +339,6 @@ fn collect_hl_recursive_inner(dir: &Path, files: &mut Vec<PathBuf>) {
590 }
339 }
591}
340}
592
341
593−fn collect_all_files_recursive(dir: &Path) -> Vec<PathBuf> {
594− let mut files = Vec::new();
595− fn walk(dir: &Path, files: &mut Vec<PathBuf>) {
596− if let Ok(entries) = fs::read_dir(dir) {
597− for entry in entries.flatten() {
598− let path = entry.path();
599− if path.is_dir() {
600− walk(&path, files);
601− } else if path.is_file() {
602− files.push(path);
603− }
604− }
605− }
606− }
607− walk(dir, &mut files);
608− files.sort();
609− files
610−}
611−
612−fn generate_sitemap(dir: &str, base_url: &str) {
613− let dir = Path::new(dir);
614− let hl_files = collect_hl_files_recursive(dir);
615− if hl_files.is_empty() {
616− eprintln!("no .hl files found in {}", dir.display());
617− process::exit(1);
618− }
619− let mut xml = String::from(
620− "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n",
621− );
622− for file in &hl_files {
623− let rel = file.strip_prefix(dir).unwrap_or(file);
624− let url_path = rel
625− .with_extension("html")
626− .to_string_lossy()
627− .replace('\\', "/");
628− let url = if url_path == "index.html" {
629− format!("{}/", base_url.trim_end_matches('/'))
630− } else {
631− format!("{}/{}", base_url.trim_end_matches('/'), url_path)
632− };
633−
634− // Get file modification time for <lastmod>
635− let lastmod = file
636− .metadata()
637− .ok()
638− .and_then(|m| m.modified().ok())
639− .map(|t| {
640− let secs = t
641− .duration_since(std::time::UNIX_EPOCH)
642− .unwrap_or_default()
643− .as_secs();
644− let days = secs / 86400;
645− // Simple date calculation from epoch days
646− let (y, m, d) = epoch_days_to_date(days);
647− format!("{:04}-{:02}-{:02}", y, m, d)
648− });
649−
650− // Heuristic priority: index pages get higher priority
651− let priority = if url_path == "index.html" {
652− "1.0"
653− } else if rel.components().count() <= 2 {
654− "0.8"
655− } else {
656− "0.5"
657− };
658−
659− xml.push_str(" <url>\n");
660− xml.push_str(&format!(" <loc>{}</loc>\n", xml_escape(&url)));
661− if let Some(ref date) = lastmod {
662− xml.push_str(&format!(" <lastmod>{}</lastmod>\n", date));
663− }
664− xml.push_str(&format!(" <priority>{}</priority>\n", priority));
665− xml.push_str(" </url>\n");
666− }
667− xml.push_str("</urlset>\n");
668− let out_path = dir.join("sitemap.xml");
669− match fs::write(&out_path, &xml) {
670− Ok(()) => eprintln!("wrote {} ({} URLs)", out_path.display(), hl_files.len()),
671− Err(e) => {
672− eprintln!("error: {}: {}", out_path.display(), e);
673− process::exit(1);
674− }
675− }
676−}
677−
678−fn epoch_days_to_date(days: u64) -> (u64, u64, u64) {
679− // Algorithm from http://howardhinnant.github.io/date_algorithms.html
680− let z = days + 719468;
681− let era = z / 146097;
682− let doe = z - era * 146097;
683− let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
684− let y = yoe + era * 400;
685− let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
686− let mp = (5 * doy + 2) / 153;
687− let d = doy - (153 * mp + 2) / 5 + 1;
688− let m = if mp < 10 { mp + 3 } else { mp - 9 };
689− let y = if m <= 2 { y + 1 } else { y };
690− (y, m, d)
691−}
692−
693fn lint_file(path: &str) -> Vec<String> {
342fn lint_file(path: &str) -> Vec<String> {
694 let input = match fs::read_to_string(path) {
343 let input = match fs::read_to_string(path) {
695 Ok(s) => s,
344 Ok(s) => s,
@@ -789,122 +438,6 @@ fn lint_nodes(nodes: &[htmlang::ast::Node], path: &str, depth: usize, warnings:
789 }
438 }
790}
439}
791
440
792−fn stats_file(path: &str) {
793− let input = match fs::read_to_string(path) {
794− Ok(s) => s,
795− Err(e) => {
796− eprintln!("error: {}: {}", path, e);
797− process::exit(1);
798− }
799− };
800− let base = Path::new(path).parent();
801− let result = htmlang::parser::parse_with_base(&input, base);
802− let html = htmlang::codegen::generate(&result.document);
803−
804− let mut element_count = 0;
805− let mut colors = std::collections::HashSet::new();
806− let mut fonts = std::collections::HashSet::new();
807− count_elements(
808− &result.document.nodes,
809− &mut element_count,
810− &mut colors,
811− &mut fonts,
812− );
813−
814− // Count CSS rules (approximate from generated style block)
815− let css_rules = html.matches('{').count().saturating_sub(1); // subtract the html/head/body structure
816−
817− let source_bytes = input.len();
818− let output_bytes = html.len();
819−
820− eprintln!("--- {} ---", path);
821− eprintln!(
822− " source size: {} bytes ({} lines)",
823− source_bytes,
824− input.lines().count()
825− );
826− eprintln!(" output size: {} bytes", output_bytes);
827− eprintln!(" elements: {}", element_count);
828− eprintln!(" CSS rules: ~{}", css_rules);
829− eprintln!(" unique colors: {}", colors.len());
830− if !colors.is_empty() {
831− let mut sorted: Vec<_> = colors.iter().collect();
832− sorted.sort();
833− eprintln!(
834− " {}",
835− sorted
836− .iter()
837− .map(|s| s.as_str())
838− .collect::<Vec<_>>()
839− .join(", ")
840− );
841− }
842− eprintln!(" unique fonts: {}", fonts.len());
843− if !fonts.is_empty() {
844− let mut sorted: Vec<_> = fonts.iter().collect();
845− sorted.sort();
846− eprintln!(
847− " {}",
848− sorted
849− .iter()
850− .map(|s| s.as_str())
851− .collect::<Vec<_>>()
852− .join(", ")
853− );
854− }
855− if result
856− .diagnostics
857− .iter()
858− .any(|d| d.severity == htmlang::parser::Severity::Error)
859− {
860− eprintln!(
861− " errors: {}",
862− result
863− .diagnostics
864− .iter()
865− .filter(|d| d.severity == htmlang::parser::Severity::Error)
866− .count()
867− );
868− }
869− let warn_count = result
870− .diagnostics
871− .iter()
872− .filter(|d| d.severity == htmlang::parser::Severity::Warning)
873− .count();
874− if warn_count > 0 {
875− eprintln!(" warnings: {}", warn_count);
876− }
877−}
878−
879−fn count_elements(
880− nodes: &[htmlang::ast::Node],
881− count: &mut usize,
882− colors: &mut std::collections::HashSet<String>,
883− fonts: &mut std::collections::HashSet<String>,
884−) {
885− for node in nodes {
886− if let htmlang::ast::Node::Element(elem) = node {
887− *count += 1;
888− for attr in &elem.attrs {
889− let key = attr.key.as_str();
890− // Strip pseudo/media prefixes for color/font detection
891− let base_key = key.split(':').next_back().unwrap_or(key);
892− if matches!(base_key, "color" | "background")
893− && let Some(ref v) = attr.value
894− {
895− colors.insert(v.clone());
896− }
897− if base_key == "font"
898− && let Some(ref v) = attr.value
899− {
900− fonts.insert(v.clone());
901− }
902− }
903− count_elements(&elem.children, count, colors, fonts);
904− }
905− }
906−}
907−
908/// Whether the build cache record at `path` matches `key` and every
441/// Whether the build cache record at `path` matches `key` and every
909/// dependency it lists still has the recorded content hash.
442/// dependency it lists still has the recorded content hash.
910fn build_cache_is_fresh(path: &Path, key: u64) -> bool {
443fn build_cache_is_fresh(path: &Path, key: u64) -> bool {
@@ -1177,26 +710,6 @@ fn load_config(target: &Path) -> ProjectConfig {
1177 config
710 config
1178}
711}
1179
712
1180−fn find_wasm_pkg() -> Option<(Vec<u8>, String)> {
1181− // Look for pre-built WASM binary and JS glue in known locations
1182− let candidates = [
1183− // Relative to current executable
1184− std::env::current_exe()
1185− .ok()
1186− .and_then(|p| p.parent().map(|d| d.join("..").join("wasm-pkg"))),
1187− // In the project build directory (development)
1188− Some(PathBuf::from("target/wasm-pkg")),
1189− ];
1190− for dir in candidates.iter().flatten() {
1191− let wasm = dir.join("htmlang_wasm_bg.wasm");
1192− let js = dir.join("htmlang_wasm.js");
1193− if let (Ok(wasm_bytes), Ok(js_str)) = (fs::read(&wasm), fs::read_to_string(&js)) {
1194− return Some((wasm_bytes, js_str));
1195− }
1196− }
1197− None
1198−}
1199−
1200fn main() {
713fn main() {
1201 let args: Vec<String> = env::args().collect();
714 let args: Vec<String> = env::args().collect();
1202 let mut watch = false;
715 let mut watch = false;
@@ -1212,38 +725,28 @@ fn main() {
1212 let mut output_path: Option<String> = None;
725 let mut output_path: Option<String> = None;
1213 let mut input_path = None;
726 let mut input_path = None;
1214
727
728+ // A bare word that isn't a path is a mistyped (or removed) subcommand.
729+ if let Some(first) = args.get(1)
730+ && !first.starts_with('-')
731+ && !cli::COMMANDS.contains(&first.as_str())
732+ && !first.ends_with(".hl")
733+ && !Path::new(first).exists()
734+ {
735+ eprintln!(
736+ "error: unknown command '{}'\ncommands: {}",
737+ first,
738+ cli::COMMANDS.join(", ")
739+ );
740+ process::exit(1);
741+ }
742+
1215 // Handle "lsp" subcommand — launch the LSP server from the main binary
743 // Handle "lsp" subcommand — launch the LSP server from the main binary
1216 if args.len() >= 2 && args[1] == "lsp" {
744 if args.len() >= 2 && args[1] == "lsp" {
1217 cli::run_lsp();
745 cli::run_lsp();
1218 return;
746 return;
1219 }
747 }
1220
748
1221− // Handle "completions" subcommand — generate shell completions
1222− if args.len() >= 2 && args[1] == "completions" {
1223− let shell = args.get(2).map(|s| s.as_str()).unwrap_or("bash");
1224− cli::print_shell_completions(shell);
1225− return;
1226− }
1227
749
1228− // Handle "init" subcommand
1229− if args.len() >= 2 && args[1] == "init" {
1230− let mut init_dir = ".";
1231− let mut init_template: Option<&str> = None;
1232− let mut i = 2;
1233− while i < args.len() {
1234− match args[i].as_str() {
1235− "--template" | "-t" => {
1236− i += 1;
1237− init_template = args.get(i).map(|s| s.as_str());
1238− }
1239− _ if init_dir == "." => init_dir = &args[i],
1240− _ => {}
1241− }
1242− i += 1;
1243− }
1244− init_project(init_dir, init_template);
1245− return;
1246− }
1247
750
1248 // Handle "fmt" subcommand
751 // Handle "fmt" subcommand
1249 if args.len() >= 3 && args[1] == "fmt" {
752 if args.len() >= 3 && args[1] == "fmt" {
@@ -1439,18 +942,6 @@ fn main() {
1439 return;
942 return;
1440 }
943 }
1441
944
1442− // Handle "sitemap" subcommand
1443− if args.len() >= 2 && args[1] == "sitemap" {
1444− let dir = if args.len() >= 3 { &args[2] } else { "." };
1445− let base_url = args
1446− .iter()
1447− .position(|a| a == "--base-url" || a == "-b")
1448− .and_then(|i| args.get(i + 1))
1449− .map(|s| s.as_str())
1450− .unwrap_or("https://example.com");
1451− generate_sitemap(dir, base_url);
1452− return;
1453− }
1454
945
1455 // Handle "lint" subcommand
946 // Handle "lint" subcommand
1456 if args.len() >= 2 && args[1] == "lint" {
947 if args.len() >= 2 && args[1] == "lint" {
@@ -1504,24 +995,6 @@ fn main() {
1504 return;
995 return;
1505 }
996 }
1506
997
1507− // Handle "stats" subcommand
1508− if args.len() >= 2 && args[1] == "stats" {
1509− let target = if args.len() >= 3 { &args[2] } else { "." };
1510− let path = Path::new(target);
1511− if path.is_dir() {
1512− let hl_files = collect_hl_files_recursive(path);
1513− if hl_files.is_empty() {
1514− eprintln!("no .hl files found in {}", target);
1515− process::exit(1);
1516− }
1517− for file in &hl_files {
1518− stats_file(&file.to_string_lossy());
1519− }
1520− } else {
1521− stats_file(target);
1522− }
1523− return;
1524− }
1525
998
1526 // Handle "check" subcommand
999 // Handle "check" subcommand
1527 if args.len() >= 2 && args[1] == "check" {
1000 if args.len() >= 2 && args[1] == "check" {
@@ -1596,301 +1069,32 @@ fn main() {
1596 return;
1069 return;
1597 }
1070 }
1598
1071
1599− // Handle "test" subcommand (run @assert directives across a project)
1600− if args.len() >= 2 && args[1] == "test" {
1601− let target = if args.len() >= 3 { &args[2] } else { "." };
1602− let path = Path::new(target);
1603− let hl_files = if path.is_dir() {
1604− collect_hl_files_recursive(path)
1605− } else {
1606− vec![PathBuf::from(target)]
1607− };
1608− if hl_files.is_empty() {
1609− eprintln!("no .hl files found in {}", target);
1610− process::exit(1);
1611− }
1612− let mut total_asserts = 0usize;
1613− let mut failed_asserts = 0usize;
1614− let mut files_with_errors = 0usize;
1615− for file in &hl_files {
1616− let input = match fs::read_to_string(file) {
1617− Ok(s) => s,
1618− Err(e) => {
1619− eprintln!("error: {}: {}", file.display(), e);
1620− files_with_errors += 1;
1621− continue;
1622− }
1623− };
1624− let base = file.parent();
1625− let result = htmlang::parser::parse_with_base(&input, base);
1626− let file_errors: Vec<_> = result
1627− .diagnostics
1628− .iter()
1629− .filter(|d| d.severity == htmlang::parser::Severity::Error)
1630− .collect();
1631− let assert_failures: Vec<_> = file_errors
1632− .iter()
1633− .filter(|d| d.message.starts_with("assertion failed:"))
1634− .collect();
1635− total_asserts += assert_failures.len();
1636− // Count @assert lines (passes + failures)
1637− let assert_count = input
1638− .lines()
1639− .filter(|l| l.trim().starts_with("@assert "))
1640− .count();
1641− total_asserts += assert_count.saturating_sub(assert_failures.len());
1642− if !assert_failures.is_empty() {
1643− files_with_errors += 1;
1644− failed_asserts += assert_failures.len();
1645− for d in &assert_failures {
1646− eprintln!("FAIL {}: line {}: {}", file.display(), d.line, d.message);
1647− }
1648− }
1649− }
1650− let passed = total_asserts.saturating_sub(failed_asserts);
1651− eprintln!(
1652− "\n{} assertions: {} passed, {} failed ({} files)",
1653− total_asserts,
1654− passed,
1655− failed_asserts,
1656− hl_files.len()
1657− );
1658− if failed_asserts > 0 || files_with_errors > 0 {
1659− process::exit(1);
1660− }
1661− return;
1662− }
1663
1072
1664− // Handle "new" subcommand
1665− if args.len() >= 2 && args[1] == "new" {
1666− if args.len() < 3 {
1667− eprintln!("usage: htmlang new <page-name>");
1668− process::exit(1);
1669− }
1670− let page_name = &args[2];
1671− let file_name = format!("{}.hl", page_name);
1672− let file_path = Path::new(&file_name);
1673− if file_path.exists() {
1674− eprintln!("error: {} already exists", file_name);
1675− process::exit(1);
1676− }
1677− let title = kebab_to_title(page_name);
1678− let template = format!(
1679− "@page {title}\n\
1680− @let primary #3b82f6\n\
1681− \n\
1682− @column [max-width 800, center-x, padding 40, spacing 20]\n\
1683− \x20\x20@text [bold, size 32] {title}\n\
1684− \x20\x20@paragraph [line-height 1.6]\n\
1685− \x20\x20\x20\x20Content goes here.\n",
1686− title = title,
1687− );
1688− match fs::write(file_path, &template) {
1689− Ok(()) => eprintln!("created {}", file_name),
1690− Err(e) => {
1691− eprintln!("error: {}: {}", file_name, e);
1692− process::exit(1);
1693− }
1694− }
1695− return;
1696− }
1697
1073
1698− // Handle "preview" subcommand
1699− if args.len() >= 2 && args[1] == "preview" {
1700− if args.len() < 3 {
1701− eprintln!("usage: htmlang preview <file.hl>");
1702− process::exit(1);
1703− }
1704− let file = &args[2];
1705− let tmp_dir = env::temp_dir().join("htmlang-preview");
1706− let _ = fs::create_dir_all(&tmp_dir);
1707− let out_path = tmp_dir.join("preview.html");
1708− let (has_errors, _) = compile(
1709− file,
1710− &CompileConfig {
1711− dev: true,
1712− output_path: Some(&out_path.to_string_lossy()),
1713− ..Default::default()
1714− },
1715− );
1716− if has_errors {
1717− process::exit(1);
1718− }
1719− let url = format!("file://{}", out_path.display());
1720− let cmd = if cfg!(target_os = "macos") {
1721− "open"
1722− } else {
1723− "xdg-open"
1724− };
1725− let _ = std::process::Command::new(cmd).arg(&url).spawn();
1726− eprintln!("opened {}", out_path.display());
1727− return;
1728− }
1729
1074
1075+
1076+
1730− // Handle "diff" subcommand
1077+ // Handle "serve" standalone subcommand
1731− if args.len() >= 2 && args[1] == "diff" {
1078+ if args.len() >= 2 && args[1] == "serve" {
1732− if args.len() < 4 {
1079+ let mut serve_target = None;
1733− eprintln!("usage: htmlang diff <file1.hl> <file2.hl>");
1080+ let mut serve_port: u16 = 3000;
1734− process::exit(1);
1081+ let mut serve_open = false;
1735− }
1082+ let mut serve_https = false;
1736− let file1 = &args[2];
1083+ let mut cert_path: Option<String> = None;
1737− let file2 = &args[3];
1084+ let mut key_path: Option<String> = None;
1738− let compile_to_string = |path: &str| -> Result<String, String> {
1085+ let mut _proxy_routes: Vec<(String, String)> = Vec::new();
1739− let input = fs::read_to_string(path).map_err(|e| format!("error: {}: {}", path, e))?;
1086+ let mut si = 2;
1740− let base = Path::new(path).parent();
1087+ while si < args.len() {
1741− let result = htmlang::parser::parse_with_base(&input, base);
1088+ match args[si].as_str() {
1742− if result
1089+ "-p" | "--port" => {
1743− .diagnostics
1090+ si += 1;
1744− .iter()
1091+ serve_port = args.get(si).and_then(|p| p.parse().ok()).unwrap_or(3000);
1745− .any(|d| d.severity == htmlang::parser::Severity::Error)
1746− {
1747− return Err(format!("error: {} has parse errors", path));
1748− }
1749− Ok(htmlang::codegen::generate_dev(&result.document))
1750− };
1751− let html1 = match compile_to_string(file1) {
1752− Ok(h) => h,
1753− Err(e) => {
1754− eprintln!("{}", e);
1755− process::exit(1);
1756− }
1757− };
1758− let html2 = match compile_to_string(file2) {
1759− Ok(h) => h,
1760− Err(e) => {
1761− eprintln!("{}", e);
1762− process::exit(1);
1763− }
1764− };
1765− let lines1: Vec<&str> = html1.lines().collect();
1766− let lines2: Vec<&str> = html2.lines().collect();
1767− let mut has_diff = false;
1768− let max_len = lines1.len().max(lines2.len());
1769− for i in 0..max_len {
1770− let l1 = lines1.get(i).unwrap_or(&"");
1771− let l2 = lines2.get(i).unwrap_or(&"");
1772− if l1 != l2 {
1773− has_diff = true;
1774− if !l1.is_empty() {
1775− println!("- {}", l1);
1776 }
1092 }
1777− if !l2.is_empty() {
1778− println!("+ {}", l2);
1779− }
1780− }
1781− }
1782− if !has_diff {
1783− eprintln!("no differences");
1784− }
1785− return;
1786− }
1787−
1788− // Handle "export" subcommand
1789− if args.len() >= 2 && args[1] == "export" {
1790− let mut src_dir = None;
1791− let mut out_file = None;
1792− let mut ei = 2;
1793− while ei < args.len() {
1794− match args[ei].as_str() {
1795− "-o" | "--output" => {
1796− ei += 1;
1797− out_file = args.get(ei).map(|s| s.as_str());
1798− }
1799− _ if src_dir.is_none() => src_dir = Some(args[ei].as_str()),
1800− _ => {
1801− eprintln!("unknown argument: {}", args[ei]);
1802− process::exit(1);
1803− }
1804− }
1805− ei += 1;
1806− }
1807− let src = src_dir.unwrap_or(".");
1808− let dir = Path::new(src);
1809− if !dir.is_dir() {
1810− eprintln!("error: '{}' is not a directory", src);
1811− process::exit(1);
1812− }
1813− let hl_files = collect_hl_files_recursive(dir);
1814− if hl_files.is_empty() {
1815− eprintln!("no .hl files found in {}", src);
1816− process::exit(1);
1817− }
1818− let zip_path = out_file.unwrap_or("site.zip");
1819− // Build to temp directory, then create zip
1820− let tmp_dir = env::temp_dir().join("htmlang-export");
1821− let _ = fs::remove_dir_all(&tmp_dir);
1822− let _ = fs::create_dir_all(&tmp_dir);
1823− let mut any_errors = false;
1824− for file in &hl_files {
1825− let rel = file.strip_prefix(dir).unwrap_or(file);
1826− let out_path = tmp_dir.join(rel).with_extension("html");
1827− if let Some(parent) = out_path.parent() {
1828− let _ = fs::create_dir_all(parent);
1829− }
1830− let path_str = file.to_string_lossy().to_string();
1831− let (has_errors, _) = compile(
1832− &path_str,
1833− &CompileConfig {
1834− output_path: Some(&out_path.to_string_lossy()),
1835− ..Default::default()
1836− },
1837− );
1838− if has_errors {
1839− any_errors = true;
1840− }
1841− }
1842− // Copy non-.hl assets
1843− copy_non_hl_files(dir, &tmp_dir);
1844− if any_errors {
1845− eprintln!("export completed with errors");
1846− }
1847− // Create a simple tar-like zip (concatenated files with headers)
1848− // Since we can't use external crates, we'll create a self-contained directory listing
1849− let export_files = collect_all_files_recursive(&tmp_dir);
1850− let zip_file = std::fs::File::create(zip_path);
1851− match zip_file {
1852− Ok(mut f) => {
1853− use std::io::Write;
1854− // Write a simple archive format: filename\0length\0content
1855− for file in &export_files {
1856− let rel = file.strip_prefix(&tmp_dir).unwrap_or(file);
1857− if let Ok(data) = fs::read(file) {
1858− let _ = write!(f, "FILE:{}:{}:", rel.display(), data.len());
1859− let _ = f.write_all(&data);
1860− }
1861− }
1862− eprintln!("exported {} files to {}", export_files.len(), zip_path);
1863− }
1864− Err(e) => {
1865− eprintln!("error: {}: {}", zip_path, e);
1866− // Fallback: just report the built files in tmp_dir
1867− eprintln!("files available in {}", tmp_dir.display());
1868− }
1869− }
1870− return;
1871− }
1872−
1873− // Handle "serve" standalone subcommand
1874− if args.len() >= 2 && args[1] == "serve" {
1875− let mut serve_target = None;
1876− let mut serve_port: u16 = 3000;
1877− let mut serve_open = false;
1878− let mut serve_https = false;
1879− let mut cert_path: Option<String> = None;
1880− let mut key_path: Option<String> = None;
1881− let mut _proxy_routes: Vec<(String, String)> = Vec::new();
1882− let mut si = 2;
1883− while si < args.len() {
1884− match args[si].as_str() {
1885− "-p" | "--port" => {
1886− si += 1;
1887− serve_port = args.get(si).and_then(|p| p.parse().ok()).unwrap_or(3000);
1888− }
1889− "--open" => serve_open = true,
1093+ "--open" => serve_open = true,
1890− "--https" => serve_https = true,
1094+ "--https" => serve_https = true,
1891− "--cert" => {
1095+ "--cert" => {
1892− si += 1;
1096+ si += 1;
1893− cert_path = args.get(si).cloned();
1097+ cert_path = args.get(si).cloned();
1894 }
1098 }
1895 "--key" => {
1099 "--key" => {
1896 si += 1;
1100 si += 1;
@@ -2171,206 +1375,23 @@ fn main() {
2171 return;
1375 return;
2172 }
1376 }
2173
1377
2174− // Handle "repl" subcommand
2175− if args.len() >= 2 && args[1] == "repl" {
2176− use std::io::{self, BufRead, Write};
2177− eprintln!("htmlang repl — type .hl code, then a blank line to compile (Ctrl+D to exit)");
2178− let stdin = io::stdin();
2179− let stdout = io::stdout();
2180− loop {
2181− eprint!("> ");
2182− let _ = io::stderr().flush();
2183− let mut buffer = String::new();
2184− loop {
2185− let mut line = String::new();
2186− match stdin.lock().read_line(&mut line) {
2187− Ok(0) => {
2188− if buffer.is_empty() {
2189− return;
2190− }
2191− break;
2192− }
2193− Ok(_) => {
2194− if line.trim().is_empty() && !buffer.is_empty() {
2195− break;
2196− }
2197− buffer.push_str(&line);
2198− }
2199− Err(_) => return,
2200− }
2201− }
2202− if buffer.trim().is_empty() {
2203− continue;
2204− }
2205− let result = htmlang::parser::parse(&buffer);
2206− for d in &result.diagnostics {
2207− let prefix = severity_label(d.severity);
2208− eprintln!("{}: line {}: {}", prefix, d.line, d.message);
2209− }
2210− if !result
2211− .diagnostics
2212− .iter()
2213− .any(|d| d.severity == htmlang::parser::Severity::Error)
2214− {
2215− let html = htmlang::codegen::generate_dev(&result.document);
2216− let _ = stdout.lock().write_all(html.as_bytes());
2217− let _ = stdout.lock().write_all(b"\n");
2218− }
2219− }
2220− }
2221
1378
2222− // Handle "feed" subcommand
2223− if args.len() >= 2 && args[1] == "feed" {
2224− let dir = if args.len() >= 3 && !args[2].starts_with('-') {
2225− &args[2]
2226− } else {
2227− "."
2228− };
2229− let base_url = args
2230− .iter()
2231− .position(|a| a == "--base-url" || a == "-b")
2232− .and_then(|i| args.get(i + 1))
2233− .map(|s| s.as_str())
2234− .unwrap_or("https://example.com");
2235− let dir_path = Path::new(dir);
2236− let hl_files = collect_hl_files_recursive(dir_path);
2237− if hl_files.is_empty() {
2238− eprintln!("no .hl files found in {}", dir);
2239− process::exit(1);
2240− }
2241− let mut items = Vec::new();
2242− for file in &hl_files {
2243− let input = match fs::read_to_string(file) {
2244− Ok(s) => s,
2245− Err(_) => continue,
2246− };
2247− let base = file.parent();
2248− let result = htmlang::parser::parse_with_base(&input, base);
2249− if let Some(ref title) = result.document.page_title {
2250− let rel = file.strip_prefix(dir_path).unwrap_or(file);
2251− let url_path = rel
2252− .with_extension("html")
2253− .to_string_lossy()
2254− .replace('\\', "/");
2255− let url = if url_path == "index.html" {
2256− format!("{}/", base_url.trim_end_matches('/'))
2257− } else {
2258− format!("{}/{}", base_url.trim_end_matches('/'), url_path)
2259− };
2260− // Get description from meta tags if available
2261− let description = result
2262− .document
2263− .meta_tags
2264− .iter()
2265− .find(|(k, _)| k == "description")
2266− .map(|(_, v)| v.clone())
2267− .unwrap_or_default();
2268− items.push((title.clone(), url, description));
2269− }
2270− }
2271− // Generate RSS 2.0 feed
2272− let site_title = items
2273− .first()
2274− .map(|(t, _, _)| t.clone())
2275− .unwrap_or_else(|| "My Site".to_string());
2276− let mut rss = format!(
2277− "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
2278− <rss version=\"2.0\">\n\
2279− <channel>\n\
2280− <title>{}</title>\n\
2281− <link>{}</link>\n\
2282− <description>RSS feed generated by htmlang</description>\n",
2283− xml_escape(&site_title),
2284− xml_escape(base_url)
2285− );
2286− for (title, url, description) in &items {
2287− rss.push_str(&format!(
2288− "<item>\n<title>{}</title>\n<link>{}</link>\n<description>{}</description>\n</item>\n",
2289− xml_escape(title),
2290− xml_escape(url),
2291− xml_escape(description)
2292− ));
2293− }
2294− rss.push_str("</channel>\n</rss>\n");
2295− let out_path = dir_path.join("feed.xml");
2296− match fs::write(&out_path, &rss) {
2297− Ok(()) => eprintln!("wrote {} ({} items)", out_path.display(), items.len()),
2298− Err(e) => {
2299− eprintln!("error: {}: {}", out_path.display(), e);
2300− process::exit(1);
2301− }
2302− }
2303− return;
2304− }
2305
1379
2306− // Handle "components" subcommand
2307− if args.len() >= 2 && args[1] == "components" {
2308− let target = if args.len() >= 3 { &args[2] } else { "." };
2309− let path = Path::new(target);
2310− let hl_files = if path.is_dir() {
2311− collect_hl_files_recursive(path)
2312− } else {
2313− vec![PathBuf::from(target)]
2314− };
2315− if hl_files.is_empty() {
2316− eprintln!("no .hl files found in {}", target);
2317− process::exit(1);
2318− }
2319− let mut total = 0usize;
2320− for file in &hl_files {
2321− let input = match fs::read_to_string(file) {
2322− Ok(s) => s,
2323− Err(_) => continue,
2324− };
2325− let mut found = Vec::new();
2326− for (line_num, line) in input.lines().enumerate() {
2327− let trimmed = line.trim();
2328− if let Some(rest) = trimmed.strip_prefix("@let ") {
2329− // Detect function definitions: @let name with indented body
2330− let parts: Vec<&str> = rest.split_whitespace().collect();
2331− if let Some(&name) = parts.first() {
2332− // Check if next line is indented (indicating a function body)
2333− let next_line = input.lines().nth(line_num + 1);
2334− let is_fn = next_line
2335− .map(|l| l.starts_with(" ") || l.starts_with('\t'))
2336− .unwrap_or(false);
2337− // Also include if it has $params (even without checking body)
2338− let has_params =
2339− parts.len() > 1 && parts[1..].iter().any(|p| p.starts_with('$'));
2340− if is_fn || has_params {
2341− let params: Vec<&str> = parts[1..]
2342− .iter()
2343− .filter(|p| p.starts_with('$'))
2344− .map(|p| p.strip_prefix('$').unwrap_or(p))
2345− .collect();
2346− let param_str = if params.is_empty() {
2347− String::new()
2348− } else {
2349− format!(" ({})", params.join(", "))
2350− };
2351− found.push((line_num + 1, name.to_string(), param_str));
2352− }
2353− }
2354− }
2355− }
2356− if !found.is_empty() {
2357− let rel = file.strip_prefix(path).unwrap_or(file);
2358− for (line, name, params) in &found {
2359− println!(" @{}{} — {}:{}", name, params, rel.display(), line);
2360− total += 1;
2361− }
2362− }
2363− }
2364− if total == 0 {
2365− eprintln!("no component definitions found");
2366− } else {
2367− eprintln!("\n{} component(s) found", total);
2368− }
2369− return;
2370− }
2371
1380
2372− // Handle "deps" subcommand — show dependency graph
1381+
1382+
1383+
1384+
1385+
1386+
1387+
1388+
1389+
1390+
1391+
1392+ // Handle "upgrade" subcommand: rewrite removed or renamed syntax to its
1393+ // current form.
2373− if args.len() >= 2 && args[1] == "deps" {
1394+ if args.len() >= 2 && args[1] == "upgrade" {
2374 let target = if args.len() >= 3 { &args[2] } else { "." };
1395 let target = if args.len() >= 3 { &args[2] } else { "." };
2375 let path = Path::new(target);
1396 let path = Path::new(target);
2376 let hl_files = if path.is_dir() {
1397 let hl_files = if path.is_dir() {
@@ -2382,1174 +1403,44 @@ fn main() {
2382 eprintln!("no .hl files found in {}", target);
1403 eprintln!("no .hl files found in {}", target);
2383 process::exit(1);
1404 process::exit(1);
2384 }
1405 }
2385− let mut graph: Vec<(String, Vec<String>)> = Vec::new();
1406+ let mut total_changes = 0usize;
1407+ let mut needs_manual = false;
2386 for file in &hl_files {
1408 for file in &hl_files {
2387 let input = match fs::read_to_string(file) {
1409 let input = match fs::read_to_string(file) {
2388 Ok(s) => s,
1410 Ok(s) => s,
2389 Err(_) => continue,
1411 Err(_) => continue,
2390 };
1412 };
2391− let rel = file.strip_prefix(path).unwrap_or(file);
1413+ let result = htmlang::upgrade::upgrade(&input);
2392− let rel_str = rel.display().to_string();
1414+ for (line, message) in &result.manual {
2393− let mut deps = Vec::new();
1415+ eprintln!("{}:{}: manual change needed: {}", file.display(), line, message);
2394− for line in input.lines() {
1416+ needs_manual = true;
2395− let trimmed = line.trim();
2396− for directive in &["@include ", "@import ", "@extends "] {
2397− if let Some(rest) = trimmed.strip_prefix(directive) {
2398− let dep = rest.split_whitespace().next().unwrap_or("").to_string();
2399− if !dep.is_empty() {
2400− deps.push(dep);
2401− }
2402− }
2403− }
2404− }
2405− graph.push((rel_str, deps));
2406− }
2407− for (file, deps) in &graph {
2408− if deps.is_empty() {
2409− println!("{} (no dependencies)", file);
2410− } else {
2411− println!("{}", file);
2412− for (i, dep) in deps.iter().enumerate() {
2413− let prefix = if i == deps.len() - 1 {
2414− "└── "
2415− } else {
2416− "├── "
2417− };
2418− println!(" {}{}", prefix, dep);
2419− }
2420− }
2421− }
2422− return;
2423− }
2424−
2425− // Handle "dead-code" subcommand — project-wide unused definitions
2426− if args.len() >= 2 && args[1] == "dead-code" {
2427− let mut dc_json = false;
2428− let mut dc_target = ".".to_string();
2429− let mut i = 2;
2430− while i < args.len() {
2431− match args[i].as_str() {
2432− "--format" => {
2433− i += 1;
2434− if args.get(i).map(|s| s.as_str()) == Some("json") {
2435− dc_json = true;
2436− }
2437− }
2438− _ if dc_target == "." => dc_target = args[i].clone(),
2439− _ => {}
2440 }
1417 }
2441− i += 1;
1418+ if result.changes > 0 {
2442− }
1419+ match fs::write(file, &result.output) {
2443− let target = dc_target.as_str();
2444− let path = Path::new(target);
2445− let hl_files = if path.is_dir() {
2446− collect_hl_files_recursive(path)
2447− } else {
2448− vec![PathBuf::from(target)]
2449− };
2450− if hl_files.is_empty() {
2451− eprintln!("no .hl files found in {}", target);
2452− process::exit(1);
2453− }
2454− // Pass 1: collect all definitions and usages across all files
2455− let mut all_let_defs: Vec<(String, String, usize)> = Vec::new(); // (name, file, line)
2456− let mut all_refs: std::collections::HashSet<String> = std::collections::HashSet::new();
2457− for file in &hl_files {
2458− let input = match fs::read_to_string(file) {
2459− Ok(s) => s,
1420+ Ok(()) => {
2460− Err(_) => continue,
1421+ eprintln!(
2461− };
1422+ "upgraded {} ({} change{})",
2462− let rel = file
2463− .strip_prefix(path)
2464− .unwrap_or(file)
2465− .display()
1423+ file.display(),
2466− .to_string();
1424+ result.changes,
2467− for (line_num, line) in input.lines().enumerate() {
1425+ if result.changes == 1 { "" } else { "s" }
2468− let trimmed = line.trim();
1426+ );
2469− if let Some(rest) = trimmed.strip_prefix("@let ")
1427+ total_changes += result.changes;
2470− && let Some(name) = rest.split_whitespace().next()
2471− {
2472− let name = name.trim_end_matches('[');
2473− all_let_defs.push((name.to_string(), rel.clone(), line_num + 1));
2474− }
2475− // Collect references: @name calls and $name usages
2476− if trimmed.starts_with('@')
2477− && !trimmed.starts_with("@let ")
2478− && !trimmed.starts_with("@include ")
2479− && !trimmed.starts_with("@import ")
2480− && !trimmed.starts_with("@extends ")
2481− && !trimmed.starts_with("@page")
2482− && !trimmed.starts_with("@meta ")
2483− && !trimmed.starts_with("@og ")
2484− && !trimmed.starts_with("@head")
2485− && !trimmed.starts_with("@style")
2486− && !trimmed.starts_with("@keyframes ")
2487− && !trimmed.starts_with("@theme")
2488− && !trimmed.starts_with("@deprecated ")
2489− && !trimmed.starts_with("@breakpoint ")
2490− && !trimmed.starts_with("@slot ")
2491− && !trimmed.starts_with("@lang ")
2492− && !trimmed.starts_with("@favicon ")
2493− && !trimmed.starts_with("--")
2494− && let Some(name) = trimmed[1..].split([' ', '[']).next()
2495− {
2496− all_refs.insert(name.to_string());
2497− }
2498− // Collect $var references
2499− let mut rest = trimmed;
2500− while let Some(pos) = rest.find('$') {
2501− let after = &rest[pos + 1..];
2502− let end = after
2503− .find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
2504− .unwrap_or(after.len());
2505− if end > 0 {
2506− all_refs.insert(after[..end].to_string());
2507 }
1428 }
2508− rest = if end < after.len() { &after[end..] } else { "" };
1429+ Err(e) => eprintln!("error: {}: {}", file.display(), e),
2509 }
1430 }
2510 }
1431 }
2511 }
1432 }
2512− // Pass 2: report unused
1433+ if total_changes == 0 && !needs_manual {
2513− let mut unused: Vec<(String, String, String, usize)> = Vec::new(); // (kind, name, file, line)
1434+ eprintln!("no upgrades needed — all files are up to date");
2514− for (name, file, line) in &all_let_defs {
1435+ } else if total_changes > 0 {
2515− if !all_refs.contains(name) && !name.starts_with("--") {
1436+ eprintln!("\n{} total change(s) applied", total_changes);
2516− unused.push(("@let".to_string(), name.clone(), file.clone(), *line));
2517− }
2518 }
1437 }
2519− if dc_json {
1438+ if needs_manual {
2520− let arr = json_array(unused.iter().map(|(kind, name, file, line)| {
1439+ process::exit(1);
2521− json_object(&[
2522− ("kind", json_escape_string(kind)),
2523− ("name", json_escape_string(name)),
2524− ("file", json_escape_string(file)),
2525− ("line", line.to_string()),
2526− ])
2527− }));
2528− println!("{}", json_object(&[("unused", arr)]));
2529− } else if unused.is_empty() {
2530− eprintln!("no unused definitions found");
2531− } else {
2532− for (kind, name, file, line) in &unused {
2533− println!("unused {} '{}' — {}:{}", kind, name, file, line);
2534− }
2535− eprintln!("\n{} unused definition(s) found", unused.len());
2536 }
1440 }
2537 return;
1441 return;
2538 }
1442 }
2539
1443
2540− // Handle "deploy" subcommand — deploy to GitHub Pages or other providers
2541− if args.len() >= 2 && args[1] == "deploy" {
2542− let mut deploy_target = ".";
2543− let mut provider = "github-pages";
2544− let mut di = 2;
2545− while di < args.len() {
2546− match args[di].as_str() {
2547− "--provider" | "-P" => {
2548− di += 1;
2549− if let Some(p) = args.get(di) {
2550− provider = match p.as_str() {
2551− "netlify" | "vercel" | "cloudflare" | "github-pages" => {
2552− // Leak to get &str lifetime — fine for CLI
2553− args[di].as_str()
2554− }
2555− _ => {
2556− eprintln!(
2557− "unknown provider: {} (supported: github-pages, netlify, vercel, cloudflare)",
2558− p
2559− );
2560− process::exit(1);
2561− }
2562− };
2563− }
2564− }
2565− _ if deploy_target == "." => deploy_target = &args[di],
2566− _ => {}
2567− }
2568− di += 1;
2569− }
2570− let target = deploy_target;
2571− let path = Path::new(target);
2572− if !path.is_dir() {
2573− eprintln!("error: '{}' is not a directory", target);
2574− process::exit(1);
2575− }
2576− // Build first
2577− let hl_files = collect_hl_files_recursive(path);
2578− if hl_files.is_empty() {
2579− eprintln!("no .hl files found in {}", target);
2580− process::exit(1);
2581− }
2582− let deploy_dir = path.join("_deploy");
2583− let _ = fs::create_dir_all(&deploy_dir);
2584− let any_errors = std::sync::atomic::AtomicBool::new(false);
2585− std::thread::scope(|s| {
2586− for file in &hl_files {
2587− let any_errors = &any_errors;
2588− let deploy_dir = &deploy_dir;
2589− s.spawn(move || {
2590− let rel = file.strip_prefix(path).unwrap_or(file);
2591− let out_path = deploy_dir.join(rel).with_extension("html");
2592− if let Some(parent) = out_path.parent() {
2593− let _ = fs::create_dir_all(parent);
2594− }
2595− let out_str = out_path.to_string_lossy().to_string();
2596− let path_str = file.to_string_lossy().to_string();
2597− let (has_errors, _) = compile(
2598− &path_str,
2599− &CompileConfig {
2600− output_path: Some(&out_str),
2601− ..Default::default()
2602− },
2603− );
2604− if has_errors {
2605− any_errors.store(true, std::sync::atomic::Ordering::Relaxed);
2606− }
2607− });
2608− }
2609− });
2610− if any_errors.load(std::sync::atomic::Ordering::Relaxed) {
2611− eprintln!("error: build failed, not deploying");
2612− process::exit(1);
2613− }
2614− copy_non_hl_files(path, &deploy_dir);
2615− eprintln!("built {} files to {}", hl_files.len(), deploy_dir.display());
2616−
2617− // Provider-specific deploy
2618− match provider {
2619− "netlify" => {
2620− eprintln!("deploying to Netlify...");
2621− let status = process::Command::new("npx")
2622− .args([
2623− "netlify-cli",
2624− "deploy",
2625− "--prod",
2626− "--dir",
2627− &deploy_dir.to_string_lossy(),
2628− ])
2629− .status();
2630− match status {
2631− Ok(s) if s.success() => eprintln!("deployed to Netlify!"),
2632− _ => {
2633− eprintln!(
2634− "netlify deploy failed — ensure netlify-cli is installed (npx netlify-cli deploy --prod --dir {})",
2635− deploy_dir.display()
2636− );
2637− process::exit(1);
2638− }
2639− }
2640− return;
2641− }
2642− "vercel" => {
2643− eprintln!("deploying to Vercel...");
2644− let status = process::Command::new("npx")
2645− .args(["vercel", "--prod", deploy_dir.to_string_lossy().as_ref()])
2646− .status();
2647− match status {
2648− Ok(s) if s.success() => eprintln!("deployed to Vercel!"),
2649− _ => {
2650− eprintln!(
2651− "vercel deploy failed — ensure vercel CLI is installed (npx vercel --prod {})",
2652− deploy_dir.display()
2653− );
2654− process::exit(1);
2655− }
2656− }
2657− return;
2658− }
2659− "cloudflare" => {
2660− eprintln!("deploying to Cloudflare Pages...");
2661− let status = process::Command::new("npx")
2662− .args([
2663− "wrangler",
2664− "pages",
2665− "deploy",
2666− deploy_dir.to_string_lossy().as_ref(),
2667− ])
2668− .status();
2669− match status {
2670− Ok(s) if s.success() => eprintln!("deployed to Cloudflare Pages!"),
2671− _ => {
2672− eprintln!(
2673− "cloudflare deploy failed — ensure wrangler is installed (npx wrangler pages deploy {})",
2674− deploy_dir.display()
2675− );
2676− process::exit(1);
2677− }
2678− }
2679− return;
2680− }
2681− _ => {} // fall through to GitHub Pages
2682− }
2683−
2684− // Deploy via gh-pages push
2685− let init_ok = matches!(
2686− process::Command::new("git")
2687− .args(["init"])
2688− .current_dir(&deploy_dir)
2689− .status(),
2690− Ok(s) if s.success()
2691− );
2692− if !init_ok {
2693− eprintln!("error: git init failed in deploy directory");
2694− process::exit(1);
2695− }
2696− let _ = process::Command::new("git")
2697− .args(["checkout", "-b", "gh-pages"])
2698− .current_dir(&deploy_dir)
2699− .status();
2700− let _ = process::Command::new("git")
2701− .args(["add", "."])
2702− .current_dir(&deploy_dir)
2703− .status();
2704− let commit_ok = matches!(
2705− process::Command::new("git")
2706− .args(["commit", "-m", "deploy"])
2707− .current_dir(&deploy_dir)
2708− .status(),
2709− Ok(s) if s.success()
2710− );
2711− if !commit_ok {
2712− eprintln!("error: git commit failed");
2713− process::exit(1);
2714− }
2715− // Check if remote origin exists in parent
2716− let remote_output = process::Command::new("git")
2717− .args(["remote", "get-url", "origin"])
2718− .output();
2719− if let Ok(output) = remote_output {
2720− if output.status.success() {
2721− let remote_url = String::from_utf8_lossy(&output.stdout).trim().to_string();
2722− let _ = process::Command::new("git")
2723− .args(["remote", "add", "origin", &remote_url])
2724− .current_dir(&deploy_dir)
2725− .status();
2726− eprintln!("pushing to gh-pages branch at {}...", remote_url);
2727− let push_ok = matches!(
2728− process::Command::new("git")
2729− .args(["push", "-f", "origin", "gh-pages"])
2730− .current_dir(&deploy_dir)
2731− .status(),
2732− Ok(s) if s.success()
2733− );
2734− if !push_ok {
2735− eprintln!(
2736− "error: push failed — run 'git push -f origin gh-pages' manually from {}",
2737− deploy_dir.display()
2738− );
2739− process::exit(1);
2740− }
2741− eprintln!("deployed to GitHub Pages!");
2742− } else {
2743− eprintln!(
2744− "no git remote found — deploy directory ready at {}",
2745− deploy_dir.display()
2746− );
2747− eprintln!(
2748− "push manually: cd {} && git remote add origin <url> && git push -f origin gh-pages",
2749− deploy_dir.display()
2750− );
2751− }
2752− }
2753− return;
2754− }
2755−
2756− // Handle "playground" subcommand — generate self-contained HTML playground
2757− if args.len() >= 2 && args[1] == "playground" {
2758− let out = if args.len() >= 3 {
2759− &args[2]
2760− } else {
2761− "playground.html"
2762− };
2763−
2764− // Load pre-built WASM binary and JS glue
2765− let (wasm_bytes, js_glue) = match find_wasm_pkg() {
2766− Some(pkg) => pkg,
2767− None => {
2768− eprintln!("error: WASM module not found. Build it first:");
2769− eprintln!(
2770− " cargo build --release --target wasm32-unknown-unknown -p htmlang-wasm"
2771− );
2772− eprintln!(
2773− " wasm-bindgen --target no-modules --no-typescript --out-dir target/wasm-pkg \\"
2774− );
2775− eprintln!(" target/wasm32-unknown-unknown/release/htmlang_wasm.wasm");
2776− process::exit(1);
2777− }
2778− };
2779− let b64 = base64_encode(&wasm_bytes);
2780− let wasm_section = format!(
2781− "<script>\n{}\n</script>\n<script>\n\
2782− (function(){{\n\
2783− var b64=\"{}\";\n\
2784− var raw=atob(b64);\n\
2785− var bytes=new Uint8Array(raw.length);\n\
2786− for(var i=0;i<raw.length;i++)bytes[i]=raw.charCodeAt(i);\n\
2787− wasm_bindgen.initSync(bytes);\n\
2788− window._wasmCompile=wasm_bindgen.compile;\n\
2789− }})();\n\
2790− </script>",
2791− js_glue, b64,
2792− );
2793− let compile_fn = "function compileSource(src){return window._wasmCompile(src);}";
2794− let editor_content = include_str!("playground_default.hl");
2795−
2796− let playground_html = format!(
2797− r##"<!DOCTYPE html>
2798−<html lang="en">
2799−<head>
2800−<meta charset="utf-8">
2801−<meta name="viewport" content="width=device-width, initial-scale=1">
2802−<title>htmlang Playground</title>
2803−<style>
2804−*{{box-sizing:border-box;margin:0}}
2805−body{{font-family:system-ui,-apple-system,sans-serif;display:flex;height:100vh;background:#1a1a2e;color:#eee}}
2806−.panel{{flex:1;display:flex;flex-direction:column;min-width:0}}
2807−.header{{padding:12px 16px;background:#16213e;border-bottom:1px solid #0f3460;display:flex;align-items:center;gap:12px}}
2808−.header h1{{font-size:14px;font-weight:600;color:#e94560}}
2809−.header .tag{{font-size:11px;padding:2px 8px;background:#0f3460;border-radius:4px;color:#a8b2d1}}
2810−.editor-wrap{{flex:1;position:relative;overflow:hidden}}
2811−textarea{{width:100%;height:100%;resize:none;border:none;outline:none;padding:16px;font-family:ui-monospace,monospace;font-size:14px;line-height:1.6;background:#1a1a2e;color:#e6e6e6;tab-size:2;position:absolute;top:0;left:0}}
2812−iframe{{flex:1;border:none;background:white}}
2813−.divider{{width:1px;background:#0f3460;cursor:col-resize}}
2814−.divider:hover{{background:#e94560}}
2815−.toolbar{{padding:8px 16px;background:#16213e;border-top:1px solid #0f3460;display:flex;gap:8px;align-items:center}}
2816−button{{padding:6px 16px;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500}}
2817−.btn-run{{background:#e94560;color:white}}
2818−.btn-run:hover{{background:#c81e45}}
2819−.btn-copy{{background:#0f3460;color:#a8b2d1}}
2820−.btn-share{{background:#0f3460;color:#a8b2d1}}
2821−.status{{margin-left:auto;font-size:12px;color:#666}}
2822−</style>
2823−</head>
2824−<body>
2825−<div class="panel" id="editor-panel">
2826−<div class="header"><h1>htmlang</h1><span class="tag">playground</span></div>
2827−<div class="editor-wrap">
2828−<textarea id="editor" spellcheck="false">{editor_content}</textarea>
2829−</div>
2830−<div class="toolbar">
2831−<button class="btn-run" onclick="compile()">&#9654; Run (Ctrl+Enter)</button>
2832−<button class="btn-copy" onclick="copyOutput()">Copy HTML</button>
2833−<button class="btn-share" onclick="share()">Share</button>
2834−<span class="status" id="status">Ready</span>
2835−</div>
2836−</div>
2837−<div class="divider" id="divider"></div>
2838−<div class="panel" id="preview-panel">
2839−<div class="header"><span class="tag">preview</span></div>
2840−<iframe id="preview"></iframe>
2841−</div>
2842−{wasm_section}
2843−<script>
2844−{compile_fn}
2845−var lastOutput='';
2846−function compile(){{
2847− var src=document.getElementById('editor').value;
2848− var status=document.getElementById('status');
2849− var start=performance.now();
2850− var html=compileSource(src);
2851− var ms=(performance.now()-start).toFixed(1);
2852− lastOutput=html;
2853− document.getElementById('preview').srcdoc=html;
2854− status.textContent='Compiled in '+ms+'ms';
2855−}}
2856−function copyOutput(){{if(lastOutput)navigator.clipboard.writeText(lastOutput).then(function(){{document.getElementById('status').textContent='Copied to clipboard!'}});}}
2857−function share(){{
2858− var src=document.getElementById('editor').value;
2859− var encoded=btoa(unescape(encodeURIComponent(src)));
2860− var url=location.origin+location.pathname+'#'+encoded;
2861− navigator.clipboard.writeText(url).then(function(){{document.getElementById('status').textContent='Share URL copied to clipboard!'}});
2862− history.replaceState(null,'','#'+encoded);
2863−}}
2864−function loadFromHash(){{
2865− if(location.hash.length>1){{
2866− try{{
2867− var decoded=decodeURIComponent(escape(atob(location.hash.slice(1))));
2868− document.getElementById('editor').value=decoded;
2869− }}catch(e){{}}
2870− }}
2871−}}
2872−// Resizable divider
2873−var divider=document.getElementById('divider');
2874−var editorPanel=document.getElementById('editor-panel');
2875−var dragging=false;
2876−divider.addEventListener('mousedown',function(){{dragging=true;document.body.style.cursor='col-resize';document.body.style.userSelect='none';}});
2877−document.addEventListener('mousemove',function(e){{if(!dragging)return;var pct=(e.clientX/window.innerWidth)*100;editorPanel.style.flex='none';editorPanel.style.width=pct+'%';}});
2878−document.addEventListener('mouseup',function(){{if(dragging){{dragging=false;document.body.style.cursor='';document.body.style.userSelect='';}}}});
2879−// Keyboard shortcuts and auto-compile
2880−var editor=document.getElementById('editor');
2881−editor.addEventListener('keydown',function(e){{
2882− if(e.key==='Enter'&&(e.ctrlKey||e.metaKey)){{e.preventDefault();compile();}}
2883− if(e.key==='Tab'){{e.preventDefault();var s=e.target;var start=s.selectionStart;s.value=s.value.substring(0,start)+' '+s.value.substring(s.selectionEnd);s.selectionStart=s.selectionEnd=start+2;}}
2884−}});
2885−loadFromHash();
2886−compile();
2887−</script>
2888−</body>
2889−</html>"##,
2890− wasm_section = wasm_section,
2891− compile_fn = compile_fn
2892− );
2893− if let Err(e) = fs::write(out, playground_html) {
2894− eprintln!("error writing {}: {}", out, e);
2895− process::exit(1);
2896− }
2897− eprintln!("open in browser: open {}", out);
2898− return;
2899− }
2900−
2901− // Handle "clean" subcommand — remove generated .html files
2902− if args.len() >= 2 && args[1] == "clean" {
2903− let target = if args.len() >= 3 { &args[2] } else { "." };
2904− let dir = Path::new(target);
2905− if !dir.is_dir() {
2906− eprintln!("error: '{}' is not a directory", target);
2907− process::exit(1);
2908− }
2909− let hl_files = collect_hl_files_recursive(dir);
2910− let mut removed = 0;
2911− for hl_file in &hl_files {
2912− let html_file = hl_file.with_extension("html");
2913− if html_file.exists() {
2914− match fs::remove_file(&html_file) {
2915− Ok(()) => {
2916− eprintln!("removed {}", html_file.display());
2917− removed += 1;
2918− }
2919− Err(e) => eprintln!("error: {}: {}", html_file.display(), e),
2920− }
2921− }
2922− }
2923− // Also remove sitemap.xml if present
2924− let sitemap = dir.join("sitemap.xml");
2925− if sitemap.exists()
2926− && let Ok(()) = fs::remove_file(&sitemap)
2927− {
2928− eprintln!("removed {}", sitemap.display());
2929− removed += 1;
2930− }
2931− eprintln!(
2932− "cleaned {} file{}",
2933− removed,
2934− if removed == 1 { "" } else { "s" }
2935− );
2936− return;
2937− }
2938−
2939− // Handle "outline" subcommand — show document structure
2940− if args.len() >= 2 && args[1] == "outline" {
2941− if args.len() < 3 {
2942− eprintln!("usage: htmlang outline <file.hl>");
2943− process::exit(1);
2944− }
2945− let path = &args[2];
2946− let input = match fs::read_to_string(path) {
2947− Ok(s) => s,
2948− Err(e) => {
2949− eprintln!("error: {}: {}", path, e);
2950− process::exit(1);
2951− }
2952− };
2953− let base = Path::new(path).parent();
2954− let result = htmlang::parser::parse_with_base(&input, base);
2955− if let Some(title) = &result.document.page_title {
2956− eprintln!("@page {}", title);
2957− }
2958− fn print_outline(nodes: &[htmlang::ast::Node], depth: usize) {
2959− let indent = " ".repeat(depth);
2960− for node in nodes {
2961− match node {
2962− htmlang::ast::Node::Element(elem) => {
2963− let kind = format!("{:?}", elem.kind);
2964− let kind = kind.split('(').next().unwrap_or(&kind);
2965− let label = if let Some(arg) = &elem.argument {
2966− format!("@{} {}", kind.to_lowercase(), arg)
2967− } else {
2968− format!("@{}", kind.to_lowercase())
2969− };
2970− eprintln!("{}{}", indent, label);
2971− print_outline(&elem.children, depth + 1);
2972− }
2973− htmlang::ast::Node::Text(segs) => {
2974− let text: String = segs
2975− .iter()
2976− .filter_map(|s| match s {
2977− htmlang::ast::TextSegment::Plain(t) => Some(t.as_str()),
2978− _ => None,
2979− })
2980− .collect();
2981− if !text.trim().is_empty() {
2982− let display = if text.chars().count() > 40 {
2983− format!("{}...", text.chars().take(37).collect::<String>())
2984− } else {
2985− text
2986− };
2987− eprintln!("{}\"{}\"", indent, display.trim());
2988− }
2989− }
2990− htmlang::ast::Node::Raw(_) => {
2991− eprintln!("{}@raw ...", indent);
2992− }
2993− }
2994− }
2995− }
2996− print_outline(&result.document.nodes, 0);
2997− return;
2998− }
2999−
3000− // Handle "doctor" subcommand — check toolchain health
3001− if args.len() >= 2 && args[1] == "doctor" {
3002− eprintln!("htmlang doctor — checking toolchain health\n");
3003− let mut ok = true;
3004−
3005− // Check htmlang version
3006− eprintln!(" htmlang: v{}", env!("CARGO_PKG_VERSION"));
3007−
3008− // Check if htmlang-lsp binary is available
3009− let lsp_status = process::Command::new("htmlang-lsp")
3010− .arg("--version")
3011− .output();
3012− match lsp_status {
3013− Ok(output) if output.status.success() => {
3014− let ver = String::from_utf8_lossy(&output.stdout);
3015− eprintln!(" htmlang-lsp: {}", ver.trim());
3016− }
3017− _ => {
3018− eprintln!(" htmlang-lsp: not found (optional — needed for editor integration)");
3019− }
3020− }
3021−
3022− // Check if cargo is available (for building from source)
3023− let cargo_status = process::Command::new("cargo").arg("--version").output();
3024− match cargo_status {
3025− Ok(output) if output.status.success() => {
3026− let ver = String::from_utf8_lossy(&output.stdout);
3027− eprintln!(" cargo: {}", ver.trim());
3028− }
3029− _ => {
3030− eprintln!(" cargo: not found");
3031− ok = false;
3032− }
3033− }
3034−
3035− // Check if git is available (for deploy)
3036− let git_status = process::Command::new("git").arg("--version").output();
3037− match git_status {
3038− Ok(output) if output.status.success() => {
3039− let ver = String::from_utf8_lossy(&output.stdout);
3040− eprintln!(" git: {}", ver.trim());
3041− }
3042− _ => {
3043− eprintln!(" git: not found (optional — needed for deploy)");
3044− }
3045− }
3046−
3047− // Check for htmlang.toml in current directory
3048− if Path::new("htmlang.toml").exists() {
3049− eprintln!(" config: htmlang.toml found");
3050− } else {
3051− eprintln!(" config: no htmlang.toml (using defaults)");
3052− }
3053−
3054− // Check for .hl files
3055− let hl_count = collect_hl_files_recursive(Path::new(".")).len();
3056− eprintln!(" .hl files: {} found in current directory", hl_count);
3057−
3058− eprintln!();
3059− if ok {
3060− eprintln!("all checks passed");
3061− } else {
3062− eprintln!("some checks failed — see above");
3063− process::exit(1);
3064− }
3065− return;
3066− }
3067−
3068− // Handle "bundle" subcommand — inline images/fonts as data URIs
3069− if args.len() >= 2 && args[1] == "bundle" {
3070− if args.len() < 3 {
3071− eprintln!("usage: htmlang bundle <file.hl | dir> [-o <out>]");
3072− process::exit(1);
3073− }
3074− let mut bundle_target = None;
3075− let mut bundle_out = None;
3076− let mut bi = 2;
3077− while bi < args.len() {
3078− match args[bi].as_str() {
3079− "-o" | "--output" => {
3080− bi += 1;
3081− bundle_out = args.get(bi).map(|s| s.as_str());
3082− }
3083− _ if bundle_target.is_none() => bundle_target = Some(args[bi].as_str()),
3084− _ => {
3085− eprintln!("unknown argument: {}", args[bi]);
3086− process::exit(1);
3087− }
3088− }
3089− bi += 1;
3090− }
3091− let target = bundle_target.unwrap_or(".");
3092− let path = Path::new(target);
3093− let hl_files = if path.is_dir() {
3094− collect_hl_files_recursive(path)
3095− } else {
3096− vec![PathBuf::from(target)]
3097− };
3098− if hl_files.is_empty() {
3099− eprintln!("no .hl files found in {}", target);
3100− process::exit(1);
3101− }
3102− for file in &hl_files {
3103− let input = match fs::read_to_string(file) {
3104− Ok(s) => s,
3105− Err(e) => {
3106− eprintln!("error: {}: {}", file.display(), e);
3107− continue;
3108− }
3109− };
3110− let base = file.parent();
3111− let result = htmlang::parser::parse_with_base(&input, base);
3112− if result
3113− .diagnostics
3114− .iter()
3115− .any(|d| d.severity == htmlang::parser::Severity::Error)
3116− {
3117− for d in &result.diagnostics {
3118− if d.severity == htmlang::parser::Severity::Error {
3119− eprintln!("error: line {}: {}", d.line, d.message);
3120− }
3121− }
3122− continue;
3123− }
3124− let html = htmlang::codegen::generate(&result.document);
3125− // Inline external assets: images and fonts referenced in the HTML
3126− let bundled = bundle_assets(&html, base.unwrap_or(Path::new(".")));
3127− let out_path = match bundle_out {
3128− Some(o) => {
3129− let p = Path::new(o);
3130− if p.is_dir() || hl_files.len() > 1 {
3131− let _ = fs::create_dir_all(p);
3132− let rel = file.strip_prefix(path).unwrap_or(file);
3133− p.join(rel).with_extension("html")
3134− } else {
3135− PathBuf::from(o)
3136− }
3137− }
3138− None => file.with_extension("html"),
3139− };
3140− match fs::write(&out_path, &bundled) {
3141− Ok(()) => eprintln!("bundled {}", out_path.display()),
3142− Err(e) => eprintln!("error: {}: {}", out_path.display(), e),
3143− }
3144− }
3145− return;
3146− }
3147−
3148− // Handle "size" subcommand — report output sizes with gzip estimates
3149− if args.len() >= 2 && args[1] == "size" {
3150− let target = if args.len() >= 3 { &args[2] } else { "." };
3151− let path = Path::new(target);
3152− let hl_files = if path.is_dir() {
3153− collect_hl_files_recursive(path)
3154− } else {
3155− vec![PathBuf::from(target)]
3156− };
3157− if hl_files.is_empty() {
3158− eprintln!("no .hl files found in {}", target);
3159− process::exit(1);
3160− }
3161− let mut total_source = 0usize;
3162− let mut total_output = 0usize;
3163− let mut total_minified = 0usize;
3164− for file in &hl_files {
3165− let input = match fs::read_to_string(file) {
3166− Ok(s) => s,
3167− Err(_) => continue,
3168− };
3169− total_source += input.len();
3170− let base = file.parent();
3171− let result = htmlang::parser::parse_with_base(&input, base);
3172− if result
3173− .diagnostics
3174− .iter()
3175− .any(|d| d.severity == htmlang::parser::Severity::Error)
3176− {
3177− continue;
3178− }
3179− let html = htmlang::codegen::generate(&result.document);
3180− let minified = htmlang::codegen::generate_minified(&result.document);
3181− let html_len = html.len();
3182− let min_len = minified.len();
3183− // Estimate gzip size as ~30% of minified (rough heuristic for HTML)
3184− let gzip_est = min_len * 30 / 100;
3185− total_output += html_len;
3186− total_minified += min_len;
3187− eprintln!(
3188− " {}: {} → {} (minified: {}, ~gzip: {})",
3189− file.display(),
3190− format_bytes(input.len()),
3191− format_bytes(html_len),
3192− format_bytes(min_len),
3193− format_bytes(gzip_est),
3194− );
3195− }
3196− eprintln!("---");
3197− let total_gzip = total_minified * 30 / 100;
3198− eprintln!(
3199− " total: {} source → {} output (minified: {}, ~gzip: {})",
3200− format_bytes(total_source),
3201− format_bytes(total_output),
3202− format_bytes(total_minified),
3203− format_bytes(total_gzip),
3204− );
3205− return;
3206− }
3207−
3208− // Handle "explain" subcommand — show CSS mapping for each line
3209− if args.len() >= 2 && args[1] == "explain" {
3210− if args.len() < 3 {
3211− eprintln!("usage: htmlang explain <file.hl>");
3212− process::exit(1);
3213− }
3214− let file = &args[2];
3215− let input = match fs::read_to_string(file) {
3216− Ok(s) => s,
3217− Err(e) => {
3218− eprintln!("error: {}: {}", file, e);
3219− process::exit(1);
3220− }
3221− };
3222− let base = Path::new(file).parent();
3223− let result = htmlang::parser::parse_with_base(&input, base);
3224− for d in &result.diagnostics {
3225− let prefix = severity_label(d.severity);
3226− eprintln!("{}: line {}: {}", prefix, d.line, d.message);
3227− }
3228− if result
3229− .diagnostics
3230− .iter()
3231− .any(|d| d.severity == htmlang::parser::Severity::Error)
3232− {
3233− process::exit(1);
3234− }
3235− let html = htmlang::codegen::generate_dev(&result.document);
3236− // Extract CSS class mappings from <style> block
3237− let mut class_css: HashMap<String, String> = HashMap::new();
3238− if let Some(style_start) = html.find("<style>")
3239− && let Some(style_end) = html[style_start..].find("</style>")
3240− {
3241− let css_block = &html[style_start + 7..style_start + style_end];
3242− // Parse each CSS rule: .className { rules }
3243− let mut pos = 0;
3244− let css_bytes = css_block.as_bytes();
3245− while pos < css_bytes.len() {
3246− if css_bytes[pos] == b'.' {
3247− let rule_start = pos;
3248− // Find class name end
3249− pos += 1;
3250− while pos < css_bytes.len()
3251− && css_bytes[pos] != b' '
3252− && css_bytes[pos] != b'{'
3253− && css_bytes[pos] != b':'
3254− && css_bytes[pos] != b','
3255− {
3256− pos += 1;
3257− }
3258− let class_name = &css_block[rule_start + 1..pos];
3259− // Find rule body
3260− while pos < css_bytes.len() && css_bytes[pos] != b'{' {
3261− pos += 1;
3262− }
3263− if pos < css_bytes.len() {
3264− pos += 1; // skip '{'
3265− let body_start = pos;
3266− let mut depth = 1;
3267− while pos < css_bytes.len() && depth > 0 {
3268− if css_bytes[pos] == b'{' {
3269− depth += 1;
3270− }
3271− if css_bytes[pos] == b'}' {
3272− depth -= 1;
3273− }
3274− pos += 1;
3275− }
3276− let body = css_block[body_start..pos.saturating_sub(1)].trim();
3277− if !body.is_empty() {
3278− class_css
3279− .entry(class_name.to_string())
3280− .and_modify(|existing| {
3281− existing.push_str("; ");
3282− existing.push_str(body);
3283− })
3284− .or_insert_with(|| body.to_string());
3285− }
3286− }
3287− } else {
3288− pos += 1;
3289− }
3290− }
3291− }
3292− // Extract data-hl-line attributes from dev HTML to map classes to source lines
3293− let mut line_classes: HashMap<usize, Vec<String>> = HashMap::new();
3294− let html_body = if let Some(body_start) = html.find("<body") {
3295− &html[body_start..]
3296− } else {
3297− &html
3298− };
3299− // Scan for elements with data-hl-line="N" and class="X"
3300− // Dev mode outputs: <div class="a" data-hl-line="2" data-hl-el="el">
3301− let mut scan_pos = 0;
3302− while scan_pos < html_body.len() {
3303− // Find each opening tag
3304− if html_body.as_bytes()[scan_pos] == b'<'
3305− && scan_pos + 1 < html_body.len()
3306− && html_body.as_bytes()[scan_pos + 1].is_ascii_alphabetic()
3307− {
3308− // Find end of tag
3309− let tag_end = html_body[scan_pos..]
3310− .find('>')
3311− .map(|p| scan_pos + p)
3312− .unwrap_or(html_body.len());
3313− let tag = &html_body[scan_pos..tag_end];
3314− // Extract data-hl-line
3315− if let Some(hl_pos) = tag.find("data-hl-line=\"") {
3316− let after = &tag[hl_pos + 14..];
3317− if let Some(end) = after.find('"')
3318− && let Ok(ln) = after[..end].parse::<usize>()
3319− {
3320− // Extract class
3321− if let Some(cls_pos) = tag.find("class=\"") {
3322− let cls_after = &tag[cls_pos + 7..];
3323− if let Some(cls_end) = cls_after.find('"') {
3324− for cls in cls_after[..cls_end].split_whitespace() {
3325− line_classes.entry(ln).or_default().push(cls.to_string());
3326− }
3327− }
3328− }
3329− }
3330− }
3331− scan_pos = tag_end;
3332− } else {
3333− scan_pos += 1;
3334− }
3335− }
3336− // Print explanation
3337− for (line_num, line) in input.lines().enumerate() {
3338− let ln = line_num + 1;
3339− let trimmed = line.trim();
3340− if trimmed.is_empty() || trimmed.starts_with("--") {
3341− continue;
3342− }
3343− print!("{:>4} | {}", ln, line);
3344− if let Some(classes) = line_classes.get(&ln) {
3345− let mut css_parts: Vec<String> = Vec::new();
3346− for cls in classes {
3347− if let Some(css) = class_css.get(cls) {
3348− css_parts.push(css.clone());
3349− }
3350− }
3351− if !css_parts.is_empty() {
3352− println!();
3353− println!(" → {}", css_parts.join("; "));
3354− } else {
3355− println!();
3356− }
3357− } else {
3358− println!();
3359− }
3360− }
3361− return;
3362− }
3363−
3364− // Handle "benchmark" subcommand — measure compile time and output size
3365− if args.len() >= 2 && args[1] == "benchmark" {
3366− if args.len() < 3 {
3367− eprintln!("usage: htmlang benchmark <file.hl | dir>");
3368− process::exit(1);
3369− }
3370− let target = &args[2];
3371− let path = Path::new(target);
3372− let hl_files = if path.is_dir() {
3373− collect_hl_files_recursive(path)
3374− } else {
3375− vec![PathBuf::from(target)]
3376− };
3377− if hl_files.is_empty() {
3378− eprintln!("no .hl files found in {}", target);
3379− process::exit(1);
3380− }
3381−
3382− let mut total_source = 0usize;
3383− let mut total_output = 0usize;
3384− let mut total_css_rules = 0usize;
3385− let mut total_elements = 0usize;
3386− let mut total_errors = 0usize;
3387−
3388− let start = std::time::Instant::now();
3389− for file in &hl_files {
3390− let input = match fs::read_to_string(file) {
3391− Ok(s) => s,
3392− Err(_) => continue,
3393− };
3394− total_source += input.len();
3395− let base = file.parent();
3396− let result = htmlang::parser::parse_with_base(&input, base);
3397− let has_errors = result
3398− .diagnostics
3399− .iter()
3400− .any(|d| d.severity == htmlang::parser::Severity::Error);
3401− if has_errors {
3402− total_errors += 1;
3403− continue;
3404− }
3405− let html = htmlang::codegen::generate(&result.document);
3406− total_output += html.len();
3407− total_css_rules += html.matches('{').count().saturating_sub(1);
3408− let mut ec = 0;
3409− let mut colors = std::collections::HashSet::new();
3410− let mut fonts = std::collections::HashSet::new();
3411− count_elements(&result.document.nodes, &mut ec, &mut colors, &mut fonts);
3412− total_elements += ec;
3413− }
3414− let elapsed = start.elapsed();
3415−
3416− eprintln!("--- benchmark results ---");
3417− eprintln!(" files: {}", hl_files.len());
3418− eprintln!(" compile time: {:.1}ms", elapsed.as_secs_f64() * 1000.0);
3419− eprintln!(" source size: {} bytes", total_source);
3420− eprintln!(" output size: {} bytes", total_output);
3421− if total_source > 0 {
3422− eprintln!(
3423− " ratio: {:.1}x",
3424− total_output as f64 / total_source as f64
3425− );
3426− }
3427− eprintln!(" elements: {}", total_elements);
3428− eprintln!(" CSS rules: ~{}", total_css_rules);
3429− if total_errors > 0 {
3430− eprintln!(" errors: {} file(s) had errors", total_errors);
3431− }
3432− if hl_files.len() > 1 {
3433− let per_file = elapsed.as_secs_f64() * 1000.0 / hl_files.len() as f64;
3434− eprintln!(" per file: {:.2}ms", per_file);
3435− }
3436− return;
3437− }
3438−
3439− // Handle "upgrade" subcommand (and its old name "migrate"): rewrite
3440− // removed or renamed syntax to its current form.
3441− if args.len() >= 2 && (args[1] == "upgrade" || args[1] == "migrate") {
3442− let target = if args.len() >= 3 { &args[2] } else { "." };
3443− let path = Path::new(target);
3444− let hl_files = if path.is_dir() {
3445− collect_hl_files_recursive(path)
3446− } else {
3447− vec![PathBuf::from(target)]
3448− };
3449− if hl_files.is_empty() {
3450− eprintln!("no .hl files found in {}", target);
3451− process::exit(1);
3452− }
3453− let mut total_changes = 0usize;
3454− let mut needs_manual = false;
3455− for file in &hl_files {
3456− let input = match fs::read_to_string(file) {
3457− Ok(s) => s,
3458− Err(_) => continue,
3459− };
3460− let result = htmlang::upgrade::upgrade(&input);
3461− for (line, message) in &result.manual {
3462− eprintln!("{}:{}: manual change needed: {}", file.display(), line, message);
3463− needs_manual = true;
3464− }
3465− if result.changes > 0 {
3466− match fs::write(file, &result.output) {
3467− Ok(()) => {
3468− eprintln!(
3469− "upgraded {} ({} change{})",
3470− file.display(),
3471− result.changes,
3472− if result.changes == 1 { "" } else { "s" }
3473− );
3474− total_changes += result.changes;
3475− }
3476− Err(e) => eprintln!("error: {}: {}", file.display(), e),
3477− }
3478− }
3479− }
3480− if total_changes == 0 && !needs_manual {
3481− eprintln!("no upgrades needed — all files are up to date");
3482− } else if total_changes > 0 {
3483− eprintln!("\n{} total change(s) applied", total_changes);
3484− }
3485− if needs_manual {
3486− process::exit(1);
3487− }
3488− return;
3489− }
3490−
3491− // Handle "create-component" subcommand — scaffold a new component
3492− if args.len() >= 2 && args[1] == "create-component" {
3493− if args.len() < 3 {
3494− eprintln!("usage: htmlang create-component <name> [param1] [param2] ...");
3495− process::exit(1);
3496− }
3497− let comp_name = &args[2];
3498− let params: Vec<&str> = args[3..].iter().map(|s| s.as_str()).collect();
3499− let file_name = format!("{}.hl", comp_name);
3500− let file_path = Path::new(&file_name);
3501− if file_path.exists() {
3502− eprintln!("error: {} already exists", file_name);
3503− process::exit(1);
3504− }
3505−
3506− let mut template = String::new();
3507− template.push_str(&format!("-- {} component\n", comp_name));
3508− template.push_str("-- Usage: @");
3509− template.push_str(comp_name);
3510− if !params.is_empty() {
3511− template.push_str(" [");
3512− for (i, p) in params.iter().enumerate() {
3513− if i > 0 {
3514− template.push_str(", ");
3515− }
3516− template.push_str(p);
3517− template.push_str(" value");
3518− }
3519− template.push(']');
3520− }
3521− template.push('\n');
3522− template.push('\n');
3523−
3524− // Generate @let component definition
3525− template.push_str("@let ");
3526− template.push_str(comp_name);
3527− for p in &params {
3528− template.push(' ');
3529− template.push('$');
3530− template.push_str(p);
3531− }
3532− template.push('\n');
3533− template.push_str(" @el [padding 16, rounded 8, border 1 #e5e7eb]\n");
3534− if !params.is_empty() {
3535− for p in &params {
3536− template.push_str(&format!(" @text ${}\n", p));
3537− }
3538− }
3539− template.push_str(" @children\n");
3540−
3541− match fs::write(file_path, &template) {
3542− Ok(()) => {
3543− eprintln!("created {}", file_name);
3544− eprintln!("import with: @import {} (then call @{})", file_name, comp_name);
3545− }
3546− Err(e) => {
3547− eprintln!("error: {}: {}", file_name, e);
3548− process::exit(1);
3549− }
3550− }
3551− return;
3552− }
3553
1444
3554 // Handle "convert" subcommand
1445 // Handle "convert" subcommand
3555 if args.len() >= 2 && args[1] == "convert" {
1446 if args.len() >= 2 && args[1] == "convert" {
src/playground_default.hldeleted−330
@@ -1,330 +0,0 @@
1−-- A comprehensive tour of htmlang: layout, style, components, and control flow.
2−@page htmlang Demo
3−@lang en
4−@meta description A one-page tour of htmlang.
5−@og title htmlang
6−@og type website
7−
8−-- Design tokens become both $vars and CSS custom properties
9−@theme
10− primary #3b82f6
11− ink #0f172a
12− muted #64748b
13− subtle #f8fafc
14−
15−-- Computed @let with arithmetic
16−@let base 8
17−@let gap = $base * 2
18−@let gutter = $gap + $base
19−
20−-- Reusable attribute bundle
21−@let card [
22− padding $gap,
23− background white,
24− rounded $base,
25− border 1 #e5e7eb,
26− shadow 0 1px 3px rgba(0,0,0,0.05)
27−]
28−
29−-- Composable style group (used with spread)
30−@let interactive [
31− cursor pointer,
32− hover:shadow 0 4px 12px rgba(0,0,0,0.08),
33− transition all 0.2s ease
34−]
35−
36−-- Keyframes using attribute syntax
37−@keyframes fade-up
38− from [opacity 0, transform translateY(8px)]
39− to [opacity 1, transform translateY(0)]
40−
41−-- Raw HTML escape hatch
42−@raw """
43−<noscript><style>.needs-js{display:none}</style></noscript>
44−"""
45−
46−-- Component with default parameter + @children slot
47−@let card $title $icon=◆
48− @article [$card, $interactive, animation fade-up 0.3s ease]
49− @row [spacing 10, align-items center]
50− @text [size 20, color $primary] $icon
51− @text [bold, size 16, color $ink] $title
52− @children
53−
54−-- Component that wraps children in a titled section (function + slot)
55−@let titled $title
56− @section [spacing 12, padding-y $base]
57− @text [bold, size 22, color $ink] $title
58− @children
59−
60−-- Top-level layout
61−@column [
62− max-width 960,
63− center-x,
64− padding 32,
65− spacing $gutter,
66− background white,
67− dark:background #0b1220,
68− dark:color #e2e8f0
69−]
70−
71− -- Header: nav + hero
72− @header [spacing $base]
73− @nav [spacing 6, color $muted, size 13]
74− @row [spacing 6, align-items center]
75− @link [color $muted] / Home
76− @text /
77− @link [color $muted] /demos Demos
78− @text /
79− @text [color $ink] htmlang
80−
81− @row [spacing $gap, align-items center]
82− @column [width fill, spacing 6]
83− @text [bold, size 40, color $ink, dark:color white] Welcome to htmlang
84− @paragraph [line-height 1.6, color $muted]
85− A minimalist layout language that compiles to
86− {@text [bold, color $primary] static HTML} — no JS, no build step.
87− Visit the {@link [color $primary, underline] https://github.com repo} for more.
88− @image [width 72, height 72, rounded 36, alt "Avatar"] https://picsum.photos/72
89−
90− @hr [border-top 1 #e5e7eb]
91−
92− -- Feature grid using @card + @titled components
93− @titled [title Features]
94− @row [wrap, gap $base]
95− @card [title Simple, icon ✦]
96− Write layouts without CSS classes.
97− @card [title Fast, icon ⚡]
98− Compiles to a single self-contained HTML file.
99− @card [title Minimal, icon ○]
100− Zero runtime JavaScript by default.
101− @card [title Typed]
102− LSP with diagnostics and go-to-definition.
103−
104− -- Typography showcase: inline elements, blockquote, code
105− @titled [title Typography]
106− @paragraph [line-height 1.7]
107− A quick brown fox jumps over {@text [bold] the lazy} {@text [italic] dog}.
108− Press {@kbd [
109− padding 2 6, background #f1f5f9, rounded 4,
110− font monospace, size 13
111− ] Ctrl+K} to search. {@mark [background #fef08a] Highlighted} uses mark.
112− {@abbr [title HyperText Markup Language + language] htmlang} is an abbreviation.
113−
114− @blockquote [
115− padding-inline-start $gap, border-left 3 $primary,
116− color $muted, italic
117− ]
118− Layout is code. Style is code. Everything should compose.
119− @cite — Anonymous
120−
121− @pre [
122− background #0f172a, color #e2e8f0,
123− padding 16, rounded 8, font monospace, size 13,
124− overflow-x auto
125− ]
126− @raw """
127−@let button $label
128− @el [padding 12, background blue]
129− @text $label
130−"""
131−
132− -- Control flow: @each, @else, @match, if()
133− @titled [title Control flow]
134− @column [spacing 6]
135− @each $name, $role in Alice Engineer, Bob Designer, Carol PM
136− @row [spacing 10, align-items baseline]
137− @text [bold, min-width 80] $name
138− @text [color $muted] $role
139−
140− -- @each ... @else fallback for empty list
141− @let nothing ""
142− @each $x in $nothing
143− @text $x
144− @else
145− @text [color $muted, italic, data-state $nothing] (Nothing — @each ... @else fallback)
146−
147− -- repeat N times
148− @row [spacing 6]
149− @each $_ in 1..6
150− @el [width 18, height 18, rounded 9, background $primary, opacity 0.4]
151−
152− -- numeric range
153− @row [spacing 4]
154− @each $i in 1..6
155− @el [
156− width 24, height 24, rounded 4,
157− background $primary, color white,
158− text-align center, size 12, bold, line-height 24
159− ]
160− $i
161−
162− -- @if / @else if / @else (with @assert to verify at compile time)
163− @let count 3
164− @assert $count == 3
165− @row [spacing 6, align-items center]
166− @text [bold, min-width 64] Count:
167− @if $count == 0
168− @text [color $muted] Empty
169− @else if $count == 1
170− @text [color $primary] Exactly one
171− @else
172− @text [color $primary, bold] $count items
173−
174− -- @match
175− @let status ready
176− @match $status
177− @case pending
178− @text [color #f59e0b] Pending…
179− @case ready
180− @text [color #10b981, bold] ✓ Ready
181− @case error
182− @text [color #ef4444] Error
183− @default
184− @text Unknown
185−
186− -- Conditional attribute value + before: pseudo-element
187− @let emphasized true
188− @el [
189− padding 12, rounded 6,
190− background if($emphasized, $primary, #e5e7eb),
191− color if($emphasized, white, $ink),
192− before:content "→ ", before:bold
193− ]
194− Conditional styling via {@code if(cond, a, b)}.
195−
196− -- Forms + pseudo-states
197− @titled [title Forms]
198− @form [spacing 10, max-width 420] /subscribe
199− @label [bold, size 13, for email] Email
200− @input [
201− id email, type email, name email,
202− placeholder you@example.com, required,
203− padding 10, border 1 #e5e7eb, rounded 6,
204− focus:border 1 $primary,
205− valid:border 1 #10b981,
206− invalid:border 1 #ef4444,
207− transition border 0.15s ease
208− ]
209− @label [bold, size 13, for role] Role
210− @select [id role, name role, padding 10, border 1 #e5e7eb, rounded 6]
211− @option [value dev] Developer
212− @option [value designer] Designer
213− @option [value other] Other
214− @label [bold, size 13, for msg] Message
215− @textarea [
216− id msg, name msg, rows 3,
217− padding 10, border 1 #e5e7eb, rounded 6
218− ]
219− @row [spacing 8]
220− @button [
221− type submit,
222− padding 10 16, background $primary, color white, bold,
223− rounded 6, border 0,
224− hover:background $primary|darken:10,
225− active:background $primary|darken:20,
226− transition background 0.15s ease,
227− cursor pointer
228− ] Subscribe
229− @button [
230− type reset,
231− padding 10 16, background white,
232− border 1 #e5e7eb, rounded 6, cursor pointer
233− ] Reset
234− @progress [value 60, max 100]
235−
236− -- Lists and tables
237− @titled [title Data]
238− @row [gap $gap, wrap]
239− @column [width fill, spacing $base, min-width 240]
240− @text [bold] Unordered
241− @list [spacing 4, list-style disc, padding-inline-start 20]
242− @item [display list-item] Composable with @each
243− @item [display list-item] Indent to nest children
244− @item [display list-item] Bold list items work too
245− @text [bold] Ordered
246− @list [ordered, list-style decimal, padding-inline-start 20]
247− @item [display list-item] First
248− @item [display list-item] Second
249− @item [display list-item] Third
250−
251− @column [width fill, spacing $base, min-width 240]
252− @text [bold] Table
253− @table
254− @thead
255− @tr
256− @th [text-align left, padding 8] Item
257− @th [text-align right, padding 8] Qty
258− @tbody
259− @tr
260− @td [padding 8, border-top 1 #e5e7eb] Widgets
261− @td [padding 8, border-top 1 #e5e7eb, text-align right] 12
262− @tr
263− @td [padding 8, border-top 1 #e5e7eb] Gadgets
264− @td [padding 8, border-top 1 #e5e7eb, text-align right] 7
265−
266− @figure [spacing 6]
267− @image [alt "A random placeholder", rounded 8] https://picsum.photos/640/200
268− @figcaption [color $muted, size 13, text-align center]
269− A placeholder image.
270−
271− -- Disclosure + named grid areas
272− @titled [title Disclosure + grid areas]
273− @details [padding 12, border 1 #e5e7eb, rounded 6, background $subtle]
274− @summary [bold, cursor pointer] What is htmlang?
275− @paragraph [color $muted, line-height 1.6, padding-block-start 6]
276− htmlang is a layout-first language that compiles to a single,
277− self-contained HTML file with embedded CSS.
278−
279− -- Implicit @el via bare [attrs] line
280− [
281− grid,
282− grid-template-columns 1fr 2fr,
283− grid-template-areas "h h" "s m" "f f",
284− gap $base,
285− padding $base, background $subtle, rounded 8
286− ]
287− @el [grid-area h, padding 10, background $primary, color white, rounded 6, text-align center]
288− Header
289− @el [grid-area s, padding 10, background white, rounded 6, border 1 #e5e7eb]
290− Sidebar
291− @el [grid-area m, padding 10, background white, rounded 6, border 1 #e5e7eb]
292− Main
293− @el [grid-area f, padding 10, background $ink, color white, rounded 6, text-align center]
294− Footer
295−
296− -- Misc: address, time, meter
297− @titled [title Misc]
298− @address [spacing 4, font-style normal]
299− @text [bold] htmlang HQ
300− @text 123 Flexbox Avenue
301− @link [color $primary] mailto:hi@htmlang.dev hi@htmlang.dev
302− @row [spacing 10, align-items center]
303− @text [size 13, color $muted] Published
304− @time [datetime 2026-04-16, size 13] April 16 2026
305− @row [spacing 10, align-items center]
306− @text [size 13, color $muted] Confidence
307− @meter [value 8, min 0, max 10, low 3, high 7] 8/10
308−
309− -- Footer: hover/active button, visited link, print-hidden
310− @footer [
311− padding-block-start $gutter, margin-block-start $gutter,
312− border-top 1 #e5e7eb, spacing $base,
313− print:display none
314− ]
315− @row [spacing $base, align-items center]
316− @el [
317− padding 12 20, background $primary, color white, bold, rounded 8,
318− hover:background $primary|darken:10,
319− active:background $primary|darken:20,
320− transition all 0.15s ease,
321− cursor pointer
322− ] > @link https://github.com
323− Get Started
324− @link [
325− color $primary,
326− visited:color #8b5cf6,
327− hover:underline
328− ] https://github.com Browse source
329− @el [width fill]
330− @text [color $muted, size 13] © 2026 htmlang