Skip to main content

afm_wasm/
lib.rs

1//! WebAssembly bindings for afm-markdown.
2//!
3//! Exposes a thin set of `#[wasm_bindgen]` exports that
4//! afm-obsidian (and other browser hosts) call across the WASM
5//! boundary. The IR shape returned by `render_afm` and
6//! `render_aozora_only` mirrors the TS `IRDocument` defined in
7//! `afm-obsidian/src/ir/types.ts` and is validated on the JS side
8//! by `from-wasm.ts`.
9//!
10//! # Stability
11//!
12//! Public exports here are version-pinned to afm-markdown's
13//! workspace version. A bump on this crate implies an afm-obsidian
14//! recompilation against the new IR shape.
15//!
16//! # Surface
17//!
18//! - [`init_panic_hook`] — opt-in panic forwarder (debug builds).
19//! - [`render_afm`] — full afm pipeline (CommonMark + GFM + aozora).
20//! - [`render_aozora_only`] — aozora-only inline mode (used by
21//!   afm-obsidian's inline post-processor; bypasses comrak).
22//! - [`hash_source`] — xxh3-64 over the source, returned as `u64`
23//!   for cache-key construction on the JS side.
24
25#![forbid(unsafe_code)]
26
27use afm_markdown::ir::{IrBlock, IrDocument};
28use afm_markdown::{Diagnostic, Options, render_blocks_to_ir, render_to_ir};
29use serde::Serialize;
30use twox_hash::XxHash3_64;
31use wasm_bindgen::prelude::*;
32
33/// Install a `console.error` panic hook for friendlier debugging.
34/// No-op when compiled without the `panic-hook` feature.
35#[wasm_bindgen(js_name = initPanicHook)]
36pub fn init_panic_hook() {
37    #[cfg(feature = "panic-hook")]
38    {
39        console_error_panic_hook::set_once();
40    }
41}
42
43/// Result envelope returned to JS. Matches the shape consumed by
44/// `afm-obsidian/src/ir/from-wasm.ts`.
45#[derive(Serialize)]
46struct RenderResult {
47    /// Structured IR — see `afm_markdown::ir` for the type tree.
48    /// Mirrors the TS `IRDocument` (camelCase fields, discriminated
49    /// unions on `kind`).
50    ir: IrDocument,
51    /// Reference HTML (post-aozora-splice + source-line anchored).
52    /// Consumers may render straight from the IR via the JS
53    /// renderers; this string is a debug / fallback surface and a
54    /// lifeline for hosts that don't ship a JS renderer.
55    html: String,
56    diagnostics: Vec<DiagnosticOut>,
57}
58
59/// Wire-format projection of [`Diagnostic`] for the JS side.
60///
61/// `level` (`"error" | "warning" | "note"`) and `source`
62/// (`"source" | "internal"`) come from the upstream stable wire-format
63/// strings. `code` is the dotted machine-readable identifier (e.g.
64/// `"aozora::lex::source_contains_pua"`). `message` is the human
65/// readable rendering via `Diagnostic`'s `Display` impl — already
66/// localised by the upstream `#[error("...")]` macro.
67#[derive(Serialize)]
68struct DiagnosticOut {
69    level: &'static str,
70    source: &'static str,
71    code: &'static str,
72    message: String,
73}
74
75impl DiagnosticOut {
76    fn from_diagnostic(d: &Diagnostic) -> Self {
77        Self {
78            level: d.severity().as_wire_str(),
79            source: d.source().as_wire_str(),
80            code: d.code(),
81            message: d.to_string(),
82        }
83    }
84}
85
86/// Optional render configuration accepted from JS. All fields are
87/// optional; missing fields fall back to `Options::afm_default()`
88/// (aozora on, anchors off).
89#[derive(serde::Deserialize, Default)]
90#[serde(rename_all = "camelCase")]
91struct RenderOptions {
92    aozora_enabled: Option<bool>,
93    source_line_anchors: Option<bool>,
94}
95
96fn build_options(opts: &RenderOptions) -> Options<'static> {
97    let mut base = Options::afm_default();
98    if let Some(v) = opts.aozora_enabled {
99        base.aozora_enabled = v;
100    }
101    if let Some(v) = opts.source_line_anchors {
102        base.source_line_anchors = v;
103    }
104    base
105}
106
107/// Render afm source to IR + HTML + diagnostics.
108///
109/// `options` is decoded as `{ aozoraEnabled?: boolean,
110/// sourceLineAnchors?: boolean }`. Both default to the values from
111/// `Options::afm_default()` (aozora on, anchors off).
112///
113/// # Errors
114///
115/// Returns `Err(JsValue::String)` when `options` cannot be deserialized
116/// from JS or when the resulting `RenderResult` cannot be serialized
117/// back to JS.
118#[wasm_bindgen(js_name = renderAfm)]
119pub fn render_afm(source: &str, options: JsValue) -> Result<JsValue, JsValue> {
120    let opts: RenderOptions = if options.is_undefined() || options.is_null() {
121        RenderOptions::default()
122    } else {
123        serde_wasm_bindgen::from_value(options).map_err(|e| JsValue::from_str(&e.to_string()))?
124    };
125    let resolved = build_options(&opts);
126    let rendered = render_to_ir(source, &resolved);
127    let result = RenderResult {
128        ir: rendered.ir,
129        html: rendered.html,
130        diagnostics: rendered
131            .diagnostics
132            .iter()
133            .map(DiagnosticOut::from_diagnostic)
134            .collect(),
135    };
136    serde_wasm_bindgen::to_value(&result).map_err(|e| JsValue::from_str(&e.to_string()))
137}
138
139/// Render aozora-only inline text (no markdown re-parse).
140///
141/// Routes through the full afm pipeline with default options.
142/// The naming preserves an entry point that callers can target
143/// without committing to the `renderAfm` shape; the implementation
144/// is intentionally a thin wrapper because the `aozora-render`
145/// boundary lives in the sibling repo (ADR-0010) and afm
146/// composes — never extends — its public API.
147///
148/// # Errors
149///
150/// Returns `Err(JsValue::String)` when the resulting `RenderResult`
151/// cannot be serialized back to JS.
152#[wasm_bindgen(js_name = renderAozoraOnly)]
153pub fn render_aozora_only(text: &str) -> Result<JsValue, JsValue> {
154    render_afm(text, JsValue::UNDEFINED)
155}
156
157/// xxh3-64 over the source, returned as a `u64` (JS receives a
158/// `bigint`). Used for cache keys.
159#[must_use]
160#[wasm_bindgen(js_name = hashSource)]
161pub fn hash_source(source: &str) -> u64 {
162    XxHash3_64::oneshot_with_seed(0, source.as_bytes())
163}
164
165#[derive(Serialize)]
166struct BlockResult {
167    /// IR blocks for this comrak top-level child. Usually one entry;
168    /// may be empty (comrak constructs without an IR projection) or
169    /// multiple (paired-container drain at the call boundary).
170    ir: Vec<IrBlock>,
171    html: String,
172    /// 1-based source line.
173    source_line: u32,
174}
175
176#[derive(Serialize)]
177struct BlocksResult {
178    blocks: Vec<BlockResult>,
179    diagnostics: Vec<DiagnosticOut>,
180}
181
182/// Per-block streaming render.
183///
184/// Returns one `{ir, html, sourceLine}` entry per top-level comrak
185/// block. The afm-obsidian bridge iterates the array and checks its
186/// `AbortSignal` between blocks (ADR-0009 chunked-cancellation
187/// strategy).
188///
189/// # Errors
190///
191/// Returns `Err(JsValue::String)` when `options` cannot be deserialized
192/// from JS or when the resulting `BlocksResult` cannot be serialized
193/// back to JS.
194#[wasm_bindgen(js_name = renderBlocks)]
195pub fn render_blocks(source: &str, options: JsValue) -> Result<JsValue, JsValue> {
196    let opts: RenderOptions = if options.is_undefined() || options.is_null() {
197        RenderOptions::default()
198    } else {
199        serde_wasm_bindgen::from_value(options).map_err(|e| JsValue::from_str(&e.to_string()))?
200    };
201    let resolved = build_options(&opts);
202    let (blocks, diagnostics) = render_blocks_to_ir(source, &resolved);
203    let result = BlocksResult {
204        blocks: blocks
205            .into_iter()
206            .map(|b| BlockResult {
207                ir: b.ir,
208                html: b.html,
209                source_line: b.source_line,
210            })
211            .collect(),
212        diagnostics: diagnostics
213            .iter()
214            .map(DiagnosticOut::from_diagnostic)
215            .collect(),
216    };
217    serde_wasm_bindgen::to_value(&result).map_err(|e| JsValue::from_str(&e.to_string()))
218}