Skip to main content

molt/
wasm.rs

1//! WebAssembly bindings.
2//!
3//! # Why this does not follow `docs/history/MOLT_SPEC.md` §4.1
4//!
5//! The spec's `WasmDocument` holds `body_nodes: Vec<String>`: it renders each node to a
6//! string on insertion and concatenates at the end. That throws the tree away, so
7//! `render_pretty()` becomes impossible, nothing can be modified after insertion, and the
8//! API is limited to the two element types the spec hard-codes.
9//!
10//! These bindings wrap the real [`Element`] and [`Document`] behind opaque handles instead.
11//! JavaScript therefore drives the *same* renderer as Rust does, which is what lets
12//! `tests/wasm.rs` build one document through both APIs and compare the bytes. If native and
13//! WASM ever diverge, that comparison catches it.
14
15//! # Handles are consumed
16//!
17//! Every builder method takes its receiver **and** any element argument by value, which in
18//! JavaScript means the handle you passed in is gone afterwards — calling a method on it
19//! throws `null pointer passed to rust`. Chain calls, or keep the returned handle:
20//!
21//! ```js
22//! const a = element("div");
23//! a.addClass("x");          // consumes `a`
24//! a.render();               // throws
25//! const b = element("div").addClass("x");   // right
26//! ```
27//!
28//! Taking arguments by value is what lets `child(el)` move the subtree instead of cloning
29//! it. Cloning made building a page quadratic in its depth, overflowed the wasm stack on a
30//! deep one, and left the original handle alive and independent, so a later change to it was
31//! silently not part of the page.
32
33use wasm_bindgen::prelude::*;
34
35use crate::core::{Element, Node, Render, RenderOptions};
36use crate::document::Document;
37use crate::seo::SeoBuilder;
38
39/// An HTML element, usable from JavaScript.
40///
41/// The builder methods consume and return the handle so they chain in JS exactly as they
42/// do in Rust:
43///
44/// ```js
45/// const card = element("div").addClass("card").child(element("h1").text("Hi"));
46/// card.render();  // '<div class="card"><h1>Hi</h1></div>'
47/// ```
48#[wasm_bindgen(js_name = WElement)]
49#[derive(Debug, Clone)]
50pub struct WElement(Element);
51
52#[wasm_bindgen(js_class = WElement)]
53impl WElement {
54    /// Creates an element with the given tag name.
55    #[wasm_bindgen(constructor)]
56    #[must_use]
57    pub fn new(tag: &str) -> Self {
58        Self(Element::new(tag))
59    }
60
61    /// Sets `id`, replacing any existing one.
62    #[wasm_bindgen(js_name = setId)]
63    #[must_use]
64    pub fn set_id(self, id: &str) -> Self {
65        Self(self.0.set_id(id))
66    }
67
68    /// Appends a class name.
69    #[wasm_bindgen(js_name = addClass)]
70    #[must_use]
71    pub fn add_class(self, class_name: &str) -> Self {
72        Self(self.0.add_class(class_name))
73    }
74
75    /// Sets `style`, replacing any existing one.
76    #[wasm_bindgen(js_name = setStyle)]
77    #[must_use]
78    pub fn set_style(self, style: &str) -> Self {
79        Self(self.0.set_style(style))
80    }
81
82    /// Appends an attribute, escaping the value.
83    ///
84    /// URL attributes go through the same policy as [`Element::attr`]: a script URL renders
85    /// as `#`.
86    #[must_use]
87    pub fn attr(self, key: &str, value: &str) -> Self {
88        Self(self.0.attr(key, value))
89    }
90
91    /// Replaces `key` with a URL written as given, even if it is a script URL.
92    ///
93    /// The value is still attribute-escaped. Use this only for a URL the page author wrote.
94    #[wasm_bindgen(js_name = trustedUrl)]
95    #[must_use]
96    pub fn trusted_url(self, key: &str, value: &str) -> Self {
97        Self(self.0.trusted_url(key, value))
98    }
99
100    /// Appends a boolean attribute, which renders as a bare key.
101    #[wasm_bindgen(js_name = boolAttr)]
102    #[must_use]
103    pub fn bool_attr(self, key: &str) -> Self {
104        Self(self.0.bool_attr(key))
105    }
106
107    /// Appends `data-{key}="{value}"`.
108    #[wasm_bindgen(js_name = dataAttr)]
109    #[must_use]
110    pub fn data_attr(self, key: &str, value: &str) -> Self {
111        Self(self.0.data_attr(key, value))
112    }
113
114    /// Appends `aria-{key}="{value}"`.
115    #[wasm_bindgen(js_name = ariaAttr)]
116    #[must_use]
117    pub fn aria_attr(self, key: &str, value: &str) -> Self {
118        Self(self.0.aria_attr(key, value))
119    }
120
121    /// Sets the text content, escaping it.
122    #[must_use]
123    pub fn text(self, content: &str) -> Self {
124        Self(self.0.text(content))
125    }
126
127    /// Sets the content **without** escaping it.
128    #[wasm_bindgen(js_name = rawText)]
129    #[must_use]
130    pub fn raw_text(self, content: &str) -> Self {
131        Self(self.0.raw_text(content))
132    }
133
134    /// Appends a child element. The child handle is consumed.
135    #[must_use]
136    pub fn child(self, child: WElement) -> Self {
137        Self(self.0.child(child.0))
138    }
139
140    /// Appends several class names.
141    #[wasm_bindgen(js_name = addClasses)]
142    #[must_use]
143    pub fn add_classes(self, classes: Vec<String>) -> Self {
144        Self(self.0.add_classes(classes))
145    }
146
147    /// Appends markup **without** escaping it, as a child.
148    ///
149    /// The element-level counterpart of `rawText`; like it, never pass user data.
150    #[wasm_bindgen(js_name = rawChild)]
151    #[must_use]
152    pub fn raw_child(self, markup: &str) -> Self {
153        Self(self.0.child(Node::raw(markup)))
154    }
155
156    /// Renders this element as `<tag … />` (or `<tag … >` in HTML mode) with no content,
157    /// even when the tag is not a void element.
158    #[wasm_bindgen(js_name = selfClosing)]
159    #[must_use]
160    pub fn self_closing(self, yes: bool) -> Self {
161        Self(self.0.self_closing(yes))
162    }
163
164    /// Appends an HTML comment as a child.
165    #[must_use]
166    pub fn comment(self, content: &str) -> Self {
167        Self(self.0.child(Node::comment(content)))
168    }
169
170    /// Renders on a single line.
171    #[must_use]
172    pub fn render(&self) -> String {
173        self.0.render()
174    }
175
176    /// Renders with indentation.
177    #[wasm_bindgen(js_name = renderPretty)]
178    #[must_use]
179    pub fn render_pretty(&self) -> String {
180        self.0.render_pretty()
181    }
182
183    /// Renders with explicit options: `pretty` turns indentation on, `indent` is the string
184    /// written per level, and `xhtml` closes void elements as `<br />`.
185    #[wasm_bindgen(js_name = renderWithOptions)]
186    #[must_use]
187    pub fn render_with_options(&self, pretty: bool, indent: &str, xhtml: bool) -> String {
188        self.0.render_with(&options(pretty, indent, xhtml))
189    }
190
191    /// Renders on a single line, as UTF-8 bytes, which spares Node a UTF-16 round trip.
192    #[wasm_bindgen(js_name = renderBytes)]
193    #[must_use]
194    pub fn render_bytes(&self) -> Vec<u8> {
195        self.0.render().into_bytes()
196    }
197}
198
199fn options(pretty: bool, indent: &str, xhtml: bool) -> RenderOptions {
200    let base = if pretty {
201        RenderOptions::pretty()
202    } else {
203        RenderOptions::compact()
204    };
205    base.with_indent(indent).with_xhtml_self_closing(xhtml)
206}
207
208/// A complete HTML document, usable from JavaScript.
209#[wasm_bindgen(js_name = WDocument)]
210#[derive(Debug, Clone)]
211pub struct WDocument(Document);
212
213#[wasm_bindgen(js_class = WDocument)]
214impl WDocument {
215    /// Creates a document. Pass `null` for `lang` to omit the attribute.
216    // `Option<String>` rather than `Option<&str>`: wasm-bindgen cannot marshal an optional
217    // borrowed string across the boundary, so the owned form is the only one that compiles.
218    #[allow(clippy::needless_pass_by_value)]
219    #[wasm_bindgen(constructor)]
220    #[must_use]
221    pub fn new(lang: Option<String>) -> Self {
222        Self(Document::new(lang.as_deref()))
223    }
224
225    /// Appends an element to the `<head>`. The element handle is consumed.
226    #[wasm_bindgen(js_name = addHead)]
227    #[must_use]
228    pub fn add_head(self, element: WElement) -> Self {
229        Self(self.0.head_children([element.0]))
230    }
231
232    /// Appends an element to the `<body>`. The element handle is consumed.
233    #[wasm_bindgen(js_name = addBody)]
234    #[must_use]
235    pub fn add_body(self, element: WElement) -> Self {
236        Self(self.0.body_children([element.0]))
237    }
238
239    /// Appends a whole SEO metadata block to the `<head>`. The `WSeo` handle is consumed.
240    // By value on purpose: wasm-bindgen only invalidates the JS handle of an argument that
241    // is taken by value, and `build` happens to need just a borrow.
242    #[allow(clippy::needless_pass_by_value)]
243    #[wasm_bindgen(js_name = addSeo)]
244    #[must_use]
245    pub fn add_seo(self, seo: WSeo) -> Self {
246        Self(self.0.head_children(seo.0.build()))
247    }
248
249    /// Renders the document, pretty-printed and prefixed by the doctype.
250    #[must_use]
251    pub fn render(&self) -> String {
252        self.0.render()
253    }
254
255    /// Renders the document on a single line.
256    #[wasm_bindgen(js_name = renderCompact)]
257    #[must_use]
258    pub fn render_compact(&self) -> String {
259        self.0.render_compact()
260    }
261
262    /// Renders with a custom indent string.
263    #[wasm_bindgen(js_name = renderWithIndent)]
264    #[must_use]
265    pub fn render_with_indent(&self, indent: &str) -> String {
266        self.0
267            .render_with(&RenderOptions::pretty().with_indent(indent))
268    }
269
270    /// Renders with explicit options, prefixed by the doctype. See
271    /// `WElement.renderWithOptions`.
272    #[wasm_bindgen(js_name = renderWithOptions)]
273    #[must_use]
274    pub fn render_with_options(&self, pretty: bool, indent: &str, xhtml: bool) -> String {
275        self.0.render_with(&options(pretty, indent, xhtml))
276    }
277}
278
279/// A page's SEO metadata block, usable from JavaScript.
280#[wasm_bindgen(js_name = WSeo)]
281#[derive(Debug, Clone)]
282pub struct WSeo(SeoBuilder);
283
284#[wasm_bindgen(js_class = WSeo)]
285impl WSeo {
286    /// Starts a metadata block.
287    #[wasm_bindgen(constructor)]
288    #[must_use]
289    pub fn new(title: &str, description: &str) -> Self {
290        Self(SeoBuilder::new(title, description))
291    }
292
293    /// Sets the preview image.
294    #[must_use]
295    pub fn image(self, image_url: &str) -> Self {
296        Self(self.0.image(image_url))
297    }
298
299    /// Sets the canonical URL.
300    #[must_use]
301    pub fn url(self, page_url: &str) -> Self {
302        Self(self.0.url(page_url))
303    }
304
305    /// Sets `twitter:site`.
306    #[wasm_bindgen(js_name = twitterSite)]
307    #[must_use]
308    pub fn twitter_site(self, site: &str) -> Self {
309        Self(self.0.twitter_site(site))
310    }
311
312    /// Renders the whole metadata block as markup.
313    #[must_use]
314    pub fn render(&self) -> String {
315        self.0.build().iter().map(Render::render).collect()
316    }
317}
318
319/// Creates an element. The idiomatic JS entry point — `element("div")` reads better than
320/// `new WElement("div")`.
321#[wasm_bindgen]
322#[must_use]
323pub fn element(tag: &str) -> WElement {
324    WElement::new(tag)
325}
326
327/// A `<script type="application/ld+json">` with the document made safe to embed: `<`, `>`
328/// and `&` become JSON escapes, so a `</script>` in a value cannot end the element.
329#[wasm_bindgen(js_name = jsonLd)]
330#[must_use]
331pub fn json_ld(json: &str) -> WElement {
332    WElement(crate::seo::json_ld(json))
333}
334
335/// Escapes text for HTML content, exposed for callers doing their own assembly.
336#[wasm_bindgen(js_name = escapeText)]
337#[must_use]
338pub fn escape_text(input: &str) -> String {
339    crate::core::escape::escape_text(input).into_owned()
340}
341
342/// The crate version, so a page can report which build produced it.
343#[wasm_bindgen]
344#[must_use]
345pub fn version() -> String {
346    env!("CARGO_PKG_VERSION").to_string()
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn trusted_url_skips_the_script_url_check() {
355        assert_eq!(
356            WElement::new("a")
357                .trusted_url("href", "javascript:print()")
358                .render(),
359            "<a href=\"javascript:print()\"></a>"
360        );
361        assert_eq!(
362            WElement::new("a").attr("href", "javascript:x").render(),
363            "<a href=\"#\"></a>"
364        );
365    }
366}