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}