Skip to main content

hsml/
lib.rs

1pub mod common;
2pub mod compiler;
3pub mod converter;
4pub mod diagnostic;
5pub mod formatter;
6pub mod parser;
7pub mod validate;
8
9use serde::Serialize;
10use wasm_bindgen::prelude::*;
11
12/// Parse HSML source and return the AST, or an error string on failure.
13fn parse_source(source: &str) -> Result<parser::RootNode, String> {
14    let span = parser::Span::new(source);
15    let (rest, ast) = parser::parse::parse(span).map_err(|e| format!("HSML parse error: {e}"))?;
16
17    if !rest.fragment().is_empty() {
18        return Err(format!(
19            "HSML parse error: unconsumed input at line {}, column {}",
20            rest.location_line(),
21            rest.get_column()
22        ));
23    }
24
25    Ok(ast)
26}
27
28/// Core compile logic shared by WASM and native callers.
29pub fn compile_content_core(source: &str) -> Result<String, String> {
30    let ast = parse_source(source)?;
31    compiler::compile(&ast, &compiler::HsmlCompileOptions::default())
32}
33
34/// Result of compiling HSML source with diagnostic support.
35#[derive(Debug, Clone, PartialEq, Serialize)]
36pub struct CompileOutput {
37    /// The compiled HTML output.
38    pub html: String,
39    /// Warnings and other non-fatal diagnostics collected during validation.
40    pub diagnostics: Vec<diagnostic::Diagnostic>,
41}
42
43/// Check HSML source for errors and warnings without compiling.
44/// Returns all diagnostics (both parse errors and validation warnings).
45pub fn check_content(source: &str) -> Vec<diagnostic::Diagnostic> {
46    let span = parser::Span::new(source);
47
48    let (rest, ast) = match parser::parse::parse(span) {
49        Ok(result) => result,
50        Err(e) => return vec![diagnostic::Diagnostic::from(&e)],
51    };
52
53    if !rest.fragment().is_empty() {
54        return vec![diagnostic::Diagnostic {
55            severity: diagnostic::Severity::Error,
56            message: "Unconsumed input".to_string(),
57            code: None,
58            location: Some(diagnostic::Location {
59                start: diagnostic::Position {
60                    line: rest.location_line(),
61                    column: rest.get_column() as u32,
62                },
63                end: diagnostic::Position {
64                    line: rest.location_line(),
65                    column: rest.get_column() as u32,
66                },
67            }),
68            file_path: None,
69        }];
70    }
71
72    validate::validate(&ast, source)
73}
74
75/// Compile HSML source, returning structured diagnostics on error.
76/// On success, returns the HTML output along with any warnings.
77pub fn compile_content_diagnostics(
78    source: &str,
79) -> Result<CompileOutput, Vec<diagnostic::Diagnostic>> {
80    compile_content_diagnostics_with_options(source, &compiler::HsmlCompileOptions::default())
81}
82
83/// Compile HSML source with custom options, returning structured diagnostics on error.
84pub fn compile_content_diagnostics_with_options(
85    source: &str,
86    options: &compiler::HsmlCompileOptions,
87) -> Result<CompileOutput, Vec<diagnostic::Diagnostic>> {
88    let span = parser::Span::new(source);
89
90    let (rest, ast) =
91        parser::parse::parse(span).map_err(|e| vec![diagnostic::Diagnostic::from(&e)])?;
92
93    if !rest.fragment().is_empty() {
94        return Err(vec![diagnostic::Diagnostic {
95            severity: diagnostic::Severity::Error,
96            message: "Unconsumed input".to_string(),
97            code: None,
98            location: Some(diagnostic::Location {
99                start: diagnostic::Position {
100                    line: rest.location_line(),
101                    column: rest.get_column() as u32,
102                },
103                end: diagnostic::Position {
104                    line: rest.location_line(),
105                    column: rest.get_column() as u32,
106                },
107            }),
108            file_path: None,
109        }]);
110    }
111
112    // Run validation to collect warnings
113    let diagnostics = validate::validate(&ast, source);
114
115    let html = compiler::compile(&ast, options)
116        .map_err(|e| vec![diagnostic::Diagnostic::compiler_error(e)])?;
117
118    Ok(CompileOutput { html, diagnostics })
119}
120
121/// Core convert logic shared by WASM and native callers.
122pub fn convert_html_core(html: &str) -> Result<String, String> {
123    converter::convert(html)
124}
125
126/// Core format logic shared by WASM and native callers.
127pub fn format_content_core(
128    source: &str,
129    options: &formatter::FormatOptions,
130) -> Result<String, String> {
131    let ast = parse_source(source)?;
132    Ok(formatter::format(&ast, options))
133}
134
135/// WASM format options that deserializes from a JS object.
136#[derive(serde::Deserialize)]
137#[serde(rename_all = "camelCase")]
138struct WasmFormatOptions {
139    #[serde(default = "default_indent_size")]
140    indent_size: usize,
141    #[serde(default = "default_print_width")]
142    print_width: usize,
143}
144
145fn default_indent_size() -> usize {
146    formatter::FormatOptions::default().indent_size
147}
148
149fn default_print_width() -> usize {
150    formatter::FormatOptions::default().print_width
151}
152
153impl From<WasmFormatOptions> for formatter::FormatOptions {
154    fn from(opts: WasmFormatOptions) -> Self {
155        Self {
156            indent_size: opts.indent_size,
157            print_width: opts.print_width,
158        }
159    }
160}
161
162/// Format HSML source, exposed as a WASM binding.
163///
164/// Returns the formatted HSML string, or a `JsError` on parse failure.
165///
166/// Options (all optional):
167/// - `indentSize` — number of spaces per indentation level (default: 2)
168/// - `printWidth` — maximum line width before wrapping attributes (default: 80)
169#[wasm_bindgen(js_name = "formatContent")]
170pub fn format_content(source: &str, options: JsValue) -> Result<String, JsError> {
171    let options: formatter::FormatOptions = if options.is_undefined() || options.is_null() {
172        formatter::FormatOptions::default()
173    } else {
174        let wasm_opts: WasmFormatOptions = serde_wasm_bindgen::from_value(options)
175            .map_err(|e| JsError::new(&format!("Invalid formatContent options: {e}")))?;
176        wasm_opts.into()
177    };
178
179    format_content_core(source, &options).map_err(|e| JsError::new(&e))
180}
181
182/// Convert HTML to HSML, exposed as a WASM binding.
183///
184/// Returns the converted HSML string, or a `JsError` on parse failure.
185#[wasm_bindgen(js_name = "convertHtml")]
186pub fn convert_html(html: &str) -> Result<String, JsError> {
187    convert_html_core(html).map_err(|e| JsError::new(&e))
188}
189
190/// Compile HSML source to HTML, exposed as a WASM binding.
191///
192/// Returns the compiled HTML string, or a `JsError` on parse/compile failure.
193#[wasm_bindgen(js_name = "compileContent")]
194pub fn compile_content(source: &str) -> Result<String, JsError> {
195    compile_content_core(source).map_err(|e| JsError::new(&e))
196}
197
198/// WASM result type that serializes to a JS object.
199#[derive(Serialize)]
200struct WasmCompileResult {
201    success: bool,
202    html: Option<String>,
203    diagnostics: Vec<diagnostic::Diagnostic>,
204}
205
206/// WASM compile options that deserializes from a JS object.
207#[derive(serde::Deserialize)]
208#[serde(rename_all = "camelCase")]
209struct WasmCompileOptions {
210    #[serde(default)]
211    pretty: bool,
212    #[serde(default = "default_compile_indent_size")]
213    indent_size: usize,
214}
215
216fn default_compile_indent_size() -> usize {
217    compiler::HsmlCompileOptions::default().indent_size
218}
219
220impl From<WasmCompileOptions> for compiler::HsmlCompileOptions {
221    fn from(opts: WasmCompileOptions) -> Self {
222        Self {
223            pretty: opts.pretty,
224            indent_size: opts.indent_size,
225        }
226    }
227}
228
229/// Compile HSML source and return a JS object with HTML output and diagnostics.
230///
231/// Returns a JS object: `{ success: boolean, html: string | null, diagnostics: Diagnostic[] }`
232///
233/// Options (all optional):
234/// - `pretty` — emit pretty-printed HTML with indentation (default: false)
235/// - `indentSize` — number of spaces per indentation level (default: 2)
236#[wasm_bindgen(js_name = "compileContentWithDiagnostics")]
237pub fn compile_content_with_diagnostics(source: &str, options: JsValue) -> JsValue {
238    let compile_options: compiler::HsmlCompileOptions =
239        if options.is_undefined() || options.is_null() {
240            compiler::HsmlCompileOptions::default()
241        } else {
242            let wasm_opts: WasmCompileOptions = match serde_wasm_bindgen::from_value(options) {
243                Ok(opts) => opts,
244                Err(_) => WasmCompileOptions {
245                    pretty: false,
246                    indent_size: default_compile_indent_size(),
247                },
248            };
249            wasm_opts.into()
250        };
251
252    let result = match compile_content_diagnostics_with_options(source, &compile_options) {
253        Ok(output) => WasmCompileResult {
254            success: true,
255            html: Some(output.html),
256            diagnostics: output.diagnostics,
257        },
258        Err(diagnostics) => WasmCompileResult {
259            success: false,
260            html: None,
261            diagnostics,
262        },
263    };
264
265    let serializer = serde_wasm_bindgen::Serializer::json_compatible();
266    use serde::Serialize;
267
268    result.serialize(&serializer).unwrap_or_else(|e| {
269        let fallback = WasmCompileResult {
270            success: false,
271            html: None,
272            diagnostics: vec![diagnostic::Diagnostic::compiler_error(format!(
273                "Failed to serialize compile result: {e}"
274            ))],
275        };
276        fallback
277            .serialize(&serializer)
278            .expect("fallback WasmCompileResult serialization should always succeed")
279    })
280}