@@ -14,10 +14,10 @@ struct DiagnosticJson {
1414}
1515
1616fn compile(input_path: &str, dev: bool, error_overlay: bool, check_only: bool, output_path: Option<&str>, format_json: bool, json_collector: Option<&Mutex<Vec<DiagnosticJson>>>) -> (bool, Vec<PathBuf>) {
17− compile_inner(input_path, dev, error_overlay, check_only, output_path, format_json, json_collector, false, false)
17+ compile_inner(input_path, dev, error_overlay, check_only, output_path, format_json, json_collector, false, false, false)
1818}
1919
20−fn compile_inner(input_path: &str, dev: bool, error_overlay: bool, check_only: bool, output_path: Option<&str>, format_json: bool, json_collector: Option<&Mutex<Vec<DiagnosticJson>>>, minify: bool, compat: bool) -> (bool, Vec<PathBuf>) {
20+fn compile_inner(input_path: &str, dev: bool, error_overlay: bool, check_only: bool, output_path: Option<&str>, format_json: bool, json_collector: Option<&Mutex<Vec<DiagnosticJson>>>, minify: bool, compat: bool, strict: bool) -> (bool, Vec<PathBuf>) {
2121 let input = match fs::read_to_string(input_path) {
2222 Ok(s) => s,
2323 Err(e) => {
@@ -72,7 +72,8 @@ fn compile_inner(input_path: &str, dev: bool, error_overlay: bool, check_only: b
7272 let has_errors = result
7373 .diagnostics
7474 .iter()
75− .any(|d| d.severity == htmlang::parser::Severity::Error);
75+ .any(|d| d.severity == htmlang::parser::Severity::Error
76+ || (strict && d.severity == htmlang::parser::Severity::Warning));
7677
7778 let out_path = match output_path {
7879 Some(p) => PathBuf::from(p),
@@ -143,6 +144,107 @@ fn kebab_to_title(s: &str) -> String {
143144 .join(" ")
144145}
145146
147+fn format_bytes(bytes: usize) -> String {
148+ if bytes >= 1_048_576 {
149+ format!("{:.1}MB", bytes as f64 / 1_048_576.0)
150+ } else if bytes >= 1024 {
151+ format!("{:.1}KB", bytes as f64 / 1024.0)
152+ } else {
153+ format!("{}B", bytes)
154+ }
155+}
156+
157+fn bundle_assets(html: &str, base_dir: &Path) -> String {
158+ use std::io::Read;
159+ let mut result = html.to_string();
160+
161+ // Inline images referenced as src="..." in <img> tags
162+ let img_re_patterns = ["src=\"", "url("];
163+ for pattern in &img_re_patterns {
164+ let mut output = String::new();
165+ let mut remaining = result.as_str();
166+ while let Some(pos) = remaining.find(pattern) {
167+ output.push_str(&remaining[..pos + pattern.len()]);
168+ remaining = &remaining[pos + pattern.len()..];
169+
170+ // Find the closing delimiter
171+ let close_char = if *pattern == "src=\"" { '"' } else { ')' };
172+ if let Some(end) = remaining.find(close_char) {
173+ let path_str = remaining[..end].trim_matches(|c| c == '\'' || c == '"');
174+
175+ // Skip data URIs, remote URLs, and anchors
176+ if !path_str.starts_with("data:")
177+ && !path_str.starts_with("http://")
178+ && !path_str.starts_with("https://")
179+ && !path_str.starts_with('#')
180+ && !path_str.is_empty()
181+ {
182+ let asset_path = base_dir.join(path_str);
183+ if asset_path.exists() {
184+ if let Ok(mut file) = std::fs::File::open(&asset_path) {
185+ let mut buf = Vec::new();
186+ if file.read_to_end(&mut buf).is_ok() {
187+ let mime = match asset_path.extension().and_then(|e| e.to_str()) {
188+ Some("png") => "image/png",
189+ Some("jpg") | Some("jpeg") => "image/jpeg",
190+ Some("gif") => "image/gif",
191+ Some("svg") => "image/svg+xml",
192+ Some("webp") => "image/webp",
193+ Some("ico") => "image/x-icon",
194+ Some("woff2") => "font/woff2",
195+ Some("woff") => "font/woff",
196+ Some("ttf") => "font/ttf",
197+ Some("otf") => "font/otf",
198+ Some("avif") => "image/avif",
199+ _ => "application/octet-stream",
200+ };
201+ use std::fmt::Write as FmtWrite;
202+ let mut b64 = String::new();
203+ // Simple base64 encoding
204+ let encoded = base64_encode(&buf);
205+ let _ = write!(b64, "data:{};base64,{}", mime, encoded);
206+ output.push_str(&b64);
207+ remaining = &remaining[end..];
208+ continue;
209+ }
210+ }
211+ }
212+ }
213+ // If we couldn't inline, keep original path
214+ output.push_str(&remaining[..end]);
215+ remaining = &remaining[end..];
216+ }
217+ }
218+ output.push_str(remaining);
219+ result = output;
220+ }
221+ result
222+}
223+
224+fn base64_encode(data: &[u8]) -> String {
225+ const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
226+ let mut result = String::with_capacity((data.len() + 2) / 3 * 4);
227+ for chunk in data.chunks(3) {
228+ let b0 = chunk[0] as u32;
229+ let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
230+ let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
231+ let n = (b0 << 16) | (b1 << 8) | b2;
232+ result.push(CHARS[((n >> 18) & 63) as usize] as char);
233+ result.push(CHARS[((n >> 12) & 63) as usize] as char);
234+ if chunk.len() > 1 {
235+ result.push(CHARS[((n >> 6) & 63) as usize] as char);
236+ } else {
237+ result.push('=');
238+ }
239+ if chunk.len() > 2 {
240+ result.push(CHARS[(n & 63) as usize] as char);
241+ } else {
242+ result.push('=');
243+ }
244+ }
245+ result
246+}
247+
146248fn copy_non_hl_files(src_dir: &Path, out_dir: &Path) {
147249 copy_non_hl_recursive(src_dir, src_dir, out_dir);
148250}
@@ -239,6 +341,8 @@ Commands:
239341 outline <file.hl> Show document structure tree
240342 doctor Check toolchain health
241343 migrate [dir|file] Auto-upgrade deprecated syntax
344+ bundle <file|dir> [-o <out>] Compile and inline all assets as data URIs
345+ size <file|dir> Report output sizes (raw, minified, ~gzip)
242346 benchmark <file|dir> Measure compile time and output size
243347
244348Options:
@@ -250,6 +354,7 @@ Options:
250354 -d, --dev Development mode
251355 -c, --check Check for errors without writing output
252356 --compat Add vendor prefixes for broader browser support
357+ --strict Treat warnings as errors (useful for CI)
253358 --format json Output diagnostics as JSON to stdout
254359 -h, --help Show this help
255360 -V, --version Show version
@@ -681,6 +786,7 @@ fn main() {
681786 let mut check = false;
682787 let mut format_json = false;
683788 let mut compat = false;
789+ let mut strict = false;
684790 let mut open_browser = false;
685791 let mut port: u16 = 3000;
686792 let mut output_path: Option<String> = None;
@@ -789,7 +895,7 @@ fn main() {
789895 }
790896 let path_str = file.to_string_lossy().to_string();
791897 let (has_errors, _) = if build_minify || build_compat {
792− compile_inner(&path_str, false, false, false, effective_out.as_deref(), false, None, build_minify, build_compat)
898+ compile_inner(&path_str, false, false, false, effective_out.as_deref(), false, None, build_minify, build_compat, false)
793899 } else {
794900 compile(&path_str, false, false, false, effective_out.as_deref(), false, None)
795901 };
@@ -2171,6 +2277,138 @@ compile();
21712277 return;
21722278 }
21732279
2280+ // Handle "bundle" subcommand — inline images/fonts as data URIs
2281+ if args.len() >= 2 && args[1] == "bundle" {
2282+ if args.len() < 3 {
2283+ eprintln!("usage: htmlang bundle <file.hl | dir> [-o <out>]");
2284+ process::exit(1);
2285+ }
2286+ let mut bundle_target = None;
2287+ let mut bundle_out = None;
2288+ let mut bi = 2;
2289+ while bi < args.len() {
2290+ match args[bi].as_str() {
2291+ "-o" | "--output" => {
2292+ bi += 1;
2293+ bundle_out = args.get(bi).map(|s| s.as_str());
2294+ }
2295+ _ if bundle_target.is_none() => bundle_target = Some(args[bi].as_str()),
2296+ _ => {
2297+ eprintln!("unknown argument: {}", args[bi]);
2298+ process::exit(1);
2299+ }
2300+ }
2301+ bi += 1;
2302+ }
2303+ let target = bundle_target.unwrap_or(".");
2304+ let path = Path::new(target);
2305+ let hl_files = if path.is_dir() {
2306+ collect_hl_files_recursive(path)
2307+ } else {
2308+ vec![PathBuf::from(target)]
2309+ };
2310+ if hl_files.is_empty() {
2311+ eprintln!("no .hl files found in {}", target);
2312+ process::exit(1);
2313+ }
2314+ for file in &hl_files {
2315+ let input = match fs::read_to_string(file) {
2316+ Ok(s) => s,
2317+ Err(e) => {
2318+ eprintln!("error: {}: {}", file.display(), e);
2319+ continue;
2320+ }
2321+ };
2322+ let base = file.parent();
2323+ let result = htmlang::parser::parse_with_base(&input, base);
2324+ if result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error) {
2325+ for d in &result.diagnostics {
2326+ if d.severity == htmlang::parser::Severity::Error {
2327+ eprintln!("error: line {}: {}", d.line, d.message);
2328+ }
2329+ }
2330+ continue;
2331+ }
2332+ let html = htmlang::codegen::generate(&result.document);
2333+ // Inline external assets: images and fonts referenced in the HTML
2334+ let bundled = bundle_assets(&html, base.unwrap_or(Path::new(".")));
2335+ let out_path = match bundle_out {
2336+ Some(o) => {
2337+ let p = Path::new(o);
2338+ if p.is_dir() || hl_files.len() > 1 {
2339+ let _ = fs::create_dir_all(p);
2340+ let rel = file.strip_prefix(path).unwrap_or(file);
2341+ p.join(rel).with_extension("html")
2342+ } else {
2343+ PathBuf::from(o)
2344+ }
2345+ }
2346+ None => file.with_extension("html"),
2347+ };
2348+ match fs::write(&out_path, &bundled) {
2349+ Ok(()) => eprintln!("bundled {}", out_path.display()),
2350+ Err(e) => eprintln!("error: {}: {}", out_path.display(), e),
2351+ }
2352+ }
2353+ return;
2354+ }
2355+
2356+ // Handle "size" subcommand — report output sizes with gzip estimates
2357+ if args.len() >= 2 && args[1] == "size" {
2358+ let target = if args.len() >= 3 { &args[2] } else { "." };
2359+ let path = Path::new(target);
2360+ let hl_files = if path.is_dir() {
2361+ collect_hl_files_recursive(path)
2362+ } else {
2363+ vec![PathBuf::from(target)]
2364+ };
2365+ if hl_files.is_empty() {
2366+ eprintln!("no .hl files found in {}", target);
2367+ process::exit(1);
2368+ }
2369+ let mut total_source = 0usize;
2370+ let mut total_output = 0usize;
2371+ let mut total_minified = 0usize;
2372+ for file in &hl_files {
2373+ let input = match fs::read_to_string(file) {
2374+ Ok(s) => s,
2375+ Err(_) => continue,
2376+ };
2377+ total_source += input.len();
2378+ let base = file.parent();
2379+ let result = htmlang::parser::parse_with_base(&input, base);
2380+ if result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error) {
2381+ continue;
2382+ }
2383+ let html = htmlang::codegen::generate(&result.document);
2384+ let minified = htmlang::codegen::generate_minified(&result.document);
2385+ let html_len = html.len();
2386+ let min_len = minified.len();
2387+ // Estimate gzip size as ~30% of minified (rough heuristic for HTML)
2388+ let gzip_est = min_len * 30 / 100;
2389+ total_output += html_len;
2390+ total_minified += min_len;
2391+ eprintln!(
2392+ " {}: {} → {} (minified: {}, ~gzip: {})",
2393+ file.display(),
2394+ format_bytes(input.len()),
2395+ format_bytes(html_len),
2396+ format_bytes(min_len),
2397+ format_bytes(gzip_est),
2398+ );
2399+ }
2400+ eprintln!("---");
2401+ let total_gzip = total_minified * 30 / 100;
2402+ eprintln!(
2403+ " total: {} source → {} output (minified: {}, ~gzip: {})",
2404+ format_bytes(total_source),
2405+ format_bytes(total_output),
2406+ format_bytes(total_minified),
2407+ format_bytes(total_gzip),
2408+ );
2409+ return;
2410+ }
2411+
21742412 // Handle "benchmark" subcommand — measure compile time and output size
21752413 if args.len() >= 2 && args[1] == "benchmark" {
21762414 if args.len() < 3 {
@@ -2427,6 +2665,7 @@ compile();
24272665 "--dev" | "-d" => dev = true,
24282666 "--check" | "-c" => check = true,
24292667 "--compat" => compat = true,
2668+ "--strict" => strict = true,
24302669 "--open" => open_browser = true,
24312670 "--format" => {
24322671 i += 1;
@@ -2517,7 +2756,7 @@ compile();
25172756 }
25182757 out_p.to_string_lossy().to_string()
25192758 });
2520− let (has_errors, included) = compile_inner(&path_str, dev, serve, check, effective_out.as_deref(), format_json, json_collector.as_ref(), false, compat);
2759+ let (has_errors, included) = compile_inner(&path_str, dev, serve, check, effective_out.as_deref(), format_json, json_collector.as_ref(), false, compat, strict);
25212760 if has_errors {
25222761 any_errors = true;
25232762 }
@@ -2560,7 +2799,7 @@ compile();
25602799
25612800 // --- Single file mode ---
25622801 let json_collector_single = if format_json { Some(Mutex::new(Vec::new())) } else { None };
2563− let (has_errors, included_files) = compile_inner(&input_path, dev, serve, check, output_path.as_deref(), format_json, json_collector_single.as_ref(), false, compat);
2802+ let (has_errors, included_files) = compile_inner(&input_path, dev, serve, check, output_path.as_deref(), format_json, json_collector_single.as_ref(), false, compat, strict);
25642803 if format_json {
25652804 if let Some(ref collector) = json_collector_single {
25662805 print_json_diagnostics(&collector.lock().unwrap());