hmd-wasm 0.1.0-alpha.8

WASM bindings for HMD parser, validator, and HTML renderer
Documentation
use hmd_core::{validate_document_with_profiles, HmdDocument, ProfileDescriptor};
use hmd_interaction::{apply_intent_json as apply_hmd_intent_json, create_patch_from_json};
use hmd_patch::{apply_patch as apply_hmd_patch, HmdPatch};
use hmd_render_html::{render_document, RenderOptions, RoundtripMode};
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ValidationResult {
    valid: bool,
    document: HmdDocument,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WasmRenderOptions {
    roundtrip: Option<String>,
}

#[wasm_bindgen(js_name = parse)]
pub fn parse(source: &str) -> Result<String, JsValue> {
    let document = hmd_parse::parse_document(source);
    json_string(&document)
}

#[wasm_bindgen(js_name = validate)]
pub fn validate(source: &str) -> Result<String, JsValue> {
    let mut document = hmd_parse::parse_document(source);
    validate_official_profiles(&mut document);
    let result = ValidationResult {
        valid: !document.has_error_diagnostics(),
        document,
    };
    json_string(&result)
}

#[wasm_bindgen(js_name = renderHtml)]
pub fn render_html(source: &str, options: Option<String>) -> Result<String, JsValue> {
    let mut document = hmd_parse::parse_document(source);
    validate_official_profiles(&mut document);
    let options = render_options(source, options)?;
    Ok(render_document(&document, options))
}

#[wasm_bindgen(js_name = applyPatch)]
pub fn apply_patch(source: &str, patch_json: &str) -> Result<String, JsValue> {
    let patch = serde_json::from_str::<HmdPatch>(patch_json)
        .map_err(|error| JsValue::from_str(&format!("invalid patch JSON: {error}")))?;
    let patched = apply_hmd_patch(source, &patch)
        .map_err(|error| JsValue::from_str(&format!("failed to apply patch: {error}")))?;
    validate_patched_source(&patched)?;
    Ok(patched)
}

#[wasm_bindgen(js_name = createPatch)]
pub fn create_patch(source: &str, intent_json: &str) -> Result<String, JsValue> {
    let patch = create_patch_from_json(source, intent_json)
        .map_err(|error| JsValue::from_str(&format!("failed to create patch: {error}")))?;
    serde_json::to_string_pretty(&patch)
        .map_err(|error| JsValue::from_str(&format!("failed to serialize patch JSON: {error}")))
}

#[wasm_bindgen(js_name = applyIntent)]
pub fn apply_intent(source: &str, intent_json: &str) -> Result<String, JsValue> {
    let patched = apply_hmd_intent_json(source, intent_json)
        .map_err(|error| JsValue::from_str(&format!("failed to apply intent: {error}")))?;
    validate_patched_source(&patched)?;
    Ok(patched)
}

fn validate_official_profiles(document: &mut HmdDocument) {
    validate_document_with_profiles(document, &official_descriptors());
}

fn validate_patched_source(source: &str) -> Result<(), JsValue> {
    let mut document = hmd_parse::parse_document(source);
    validate_official_profiles(&mut document);
    if !document.has_error_diagnostics() {
        return Ok(());
    }

    let errors = document
        .diagnostics
        .iter()
        .filter(|diagnostic| diagnostic.severity == hmd_core::DiagnosticSeverity::Error)
        .map(|diagnostic| format!("{}: {}", diagnostic.code, diagnostic.message))
        .collect::<Vec<_>>()
        .join("; ");
    Err(JsValue::from_str(&format!(
        "patched HMD failed validation: {errors}"
    )))
}

fn official_descriptors() -> [ProfileDescriptor; 4] {
    [
        hmd_profile_general::descriptor(),
        hmd_profile_decision::descriptor(),
        hmd_profile_progress::descriptor(),
        hmd_profile_todo::descriptor(),
    ]
}

fn render_options(source: &str, options: Option<String>) -> Result<RenderOptions, JsValue> {
    let mut render_options = RenderOptions::default().with_source(source);
    let Some(options) = options else {
        return Ok(render_options);
    };
    if options.trim().is_empty() {
        return Ok(render_options);
    }

    let parsed: WasmRenderOptions = serde_json::from_str(&options)
        .map_err(|error| JsValue::from_str(&format!("invalid render options JSON: {error}")))?;
    if let Some(roundtrip) = parsed.roundtrip {
        render_options = render_options.with_roundtrip(parse_roundtrip_mode(&roundtrip)?);
    }
    Ok(render_options)
}

fn parse_roundtrip_mode(value: &str) -> Result<RoundtripMode, JsValue> {
    match value {
        "view" => Ok(RoundtripMode::View),
        "semantic" => Ok(RoundtripMode::Semantic),
        "exact" => Ok(RoundtripMode::Exact),
        _ => Err(JsValue::from_str(&format!(
            "unknown roundtrip mode '{value}', expected view, semantic, or exact"
        ))),
    }
}

fn json_string<T: Serialize>(value: &T) -> Result<String, JsValue> {
    serde_json::to_string(value)
        .map_err(|error| JsValue::from_str(&format!("failed to serialize JSON: {error}")))
}