Skip to main content

molt/
wasm.rs

1//! WebAssembly bindings.
2//!
3//! # Why this does not follow `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
15use wasm_bindgen::prelude::*;
16
17use crate::core::{Element, Node, Render, RenderOptions};
18use crate::document::Document;
19use crate::seo::SeoBuilder;
20
21/// An HTML element, usable from JavaScript.
22///
23/// The builder methods consume and return the handle so they chain in JS exactly as they
24/// do in Rust:
25///
26/// ```js
27/// const card = wElement("div").addClass("card").child(wElement("h1").text("Hi"));
28/// card.render();  // '<div class="card"><h1>Hi</h1></div>'
29/// ```
30#[wasm_bindgen(js_name = WElement)]
31#[derive(Debug, Clone)]
32pub struct WElement(Element);
33
34#[wasm_bindgen(js_class = WElement)]
35impl WElement {
36    /// Creates an element with the given tag name.
37    #[wasm_bindgen(constructor)]
38    #[must_use]
39    pub fn new(tag: &str) -> Self {
40        Self(Element::new(tag))
41    }
42
43    /// Sets `id`, replacing any existing one.
44    #[wasm_bindgen(js_name = setId)]
45    #[must_use]
46    pub fn set_id(self, id: &str) -> Self {
47        Self(self.0.set_id(id))
48    }
49
50    /// Appends a class name.
51    #[wasm_bindgen(js_name = addClass)]
52    #[must_use]
53    pub fn add_class(self, class_name: &str) -> Self {
54        Self(self.0.add_class(class_name))
55    }
56
57    /// Sets `style`, replacing any existing one.
58    #[wasm_bindgen(js_name = setStyle)]
59    #[must_use]
60    pub fn set_style(self, style: &str) -> Self {
61        Self(self.0.set_style(style))
62    }
63
64    /// Appends an attribute, escaping the value.
65    ///
66    /// URL attributes go through the same policy as [`Element::attr`]: a script URL renders
67    /// as `#`.
68    #[must_use]
69    pub fn attr(self, key: &str, value: &str) -> Self {
70        Self(self.0.attr(key, value))
71    }
72
73    /// Replaces `key` with a URL written as given, even if it is a script URL.
74    ///
75    /// The value is still attribute-escaped. Use this only for a URL the page author wrote.
76    #[wasm_bindgen(js_name = trustedUrl)]
77    #[must_use]
78    pub fn trusted_url(self, key: &str, value: &str) -> Self {
79        Self(self.0.trusted_url(key, value))
80    }
81
82    /// Appends a boolean attribute, which renders as a bare key.
83    #[wasm_bindgen(js_name = boolAttr)]
84    #[must_use]
85    pub fn bool_attr(self, key: &str) -> Self {
86        Self(self.0.bool_attr(key))
87    }
88
89    /// Appends `data-{key}="{value}"`.
90    #[wasm_bindgen(js_name = dataAttr)]
91    #[must_use]
92    pub fn data_attr(self, key: &str, value: &str) -> Self {
93        Self(self.0.data_attr(key, value))
94    }
95
96    /// Appends `aria-{key}="{value}"`.
97    #[wasm_bindgen(js_name = ariaAttr)]
98    #[must_use]
99    pub fn aria_attr(self, key: &str, value: &str) -> Self {
100        Self(self.0.aria_attr(key, value))
101    }
102
103    /// Sets the text content, escaping it.
104    #[must_use]
105    pub fn text(self, content: &str) -> Self {
106        Self(self.0.text(content))
107    }
108
109    /// Sets the content **without** escaping it.
110    #[wasm_bindgen(js_name = rawText)]
111    #[must_use]
112    pub fn raw_text(self, content: &str) -> Self {
113        Self(self.0.raw_text(content))
114    }
115
116    /// Appends a child element.
117    #[must_use]
118    pub fn child(self, child: &WElement) -> Self {
119        Self(self.0.child(child.0.clone()))
120    }
121
122    /// Appends an HTML comment as a child.
123    #[must_use]
124    pub fn comment(self, content: &str) -> Self {
125        Self(self.0.child(Node::comment(content)))
126    }
127
128    /// Renders on a single line.
129    #[must_use]
130    pub fn render(&self) -> String {
131        self.0.render()
132    }
133
134    /// Renders with indentation.
135    #[wasm_bindgen(js_name = renderPretty)]
136    #[must_use]
137    pub fn render_pretty(&self) -> String {
138        self.0.render_pretty()
139    }
140}
141
142/// A complete HTML document, usable from JavaScript.
143#[wasm_bindgen(js_name = WDocument)]
144#[derive(Debug, Clone)]
145pub struct WDocument(Document);
146
147#[wasm_bindgen(js_class = WDocument)]
148impl WDocument {
149    /// Creates a document. Pass `null` for `lang` to omit the attribute.
150    // `Option<String>` rather than `Option<&str>`: wasm-bindgen cannot marshal an optional
151    // borrowed string across the boundary, so the owned form is the only one that compiles.
152    #[allow(clippy::needless_pass_by_value)]
153    #[wasm_bindgen(constructor)]
154    #[must_use]
155    pub fn new(lang: Option<String>) -> Self {
156        Self(Document::new(lang.as_deref()))
157    }
158
159    /// Appends an element to the `<head>`.
160    #[wasm_bindgen(js_name = addHead)]
161    #[must_use]
162    pub fn add_head(self, element: &WElement) -> Self {
163        Self(self.0.head_children([element.0.clone()]))
164    }
165
166    /// Appends an element to the `<body>`.
167    #[wasm_bindgen(js_name = addBody)]
168    #[must_use]
169    pub fn add_body(self, element: &WElement) -> Self {
170        Self(self.0.body_children([element.0.clone()]))
171    }
172
173    /// Renders the document, pretty-printed and prefixed by the doctype.
174    #[must_use]
175    pub fn render(&self) -> String {
176        self.0.render()
177    }
178
179    /// Renders the document on a single line.
180    #[wasm_bindgen(js_name = renderCompact)]
181    #[must_use]
182    pub fn render_compact(&self) -> String {
183        self.0.render_compact()
184    }
185
186    /// Renders with a custom indent string.
187    #[wasm_bindgen(js_name = renderWithIndent)]
188    #[must_use]
189    pub fn render_with_indent(&self, indent: &str) -> String {
190        self.0
191            .render_with(&RenderOptions::pretty().with_indent(indent))
192    }
193}
194
195/// A page's SEO metadata block, usable from JavaScript.
196#[wasm_bindgen(js_name = WSeo)]
197#[derive(Debug, Clone)]
198pub struct WSeo(SeoBuilder);
199
200#[wasm_bindgen(js_class = WSeo)]
201impl WSeo {
202    /// Starts a metadata block.
203    #[wasm_bindgen(constructor)]
204    #[must_use]
205    pub fn new(title: &str, description: &str) -> Self {
206        Self(SeoBuilder::new(title, description))
207    }
208
209    /// Sets the preview image.
210    #[must_use]
211    pub fn image(self, image_url: &str) -> Self {
212        Self(self.0.image(image_url))
213    }
214
215    /// Sets the canonical URL.
216    #[must_use]
217    pub fn url(self, page_url: &str) -> Self {
218        Self(self.0.url(page_url))
219    }
220
221    /// Sets `twitter:site`.
222    #[wasm_bindgen(js_name = twitterSite)]
223    #[must_use]
224    pub fn twitter_site(self, site: &str) -> Self {
225        Self(self.0.twitter_site(site))
226    }
227
228    /// Renders the whole metadata block as markup.
229    #[must_use]
230    pub fn render(&self) -> String {
231        self.0.build().iter().map(Render::render).collect()
232    }
233}
234
235/// Creates an element. The idiomatic JS entry point — `element("div")` reads better than
236/// `new WElement("div")`.
237#[wasm_bindgen]
238#[must_use]
239pub fn element(tag: &str) -> WElement {
240    WElement::new(tag)
241}
242
243/// Escapes text for HTML content, exposed for callers doing their own assembly.
244#[wasm_bindgen(js_name = escapeText)]
245#[must_use]
246pub fn escape_text(input: &str) -> String {
247    crate::core::escape::escape_text(input)
248}
249
250/// The crate version, so a page can report which build produced it.
251#[wasm_bindgen]
252#[must_use]
253pub fn version() -> String {
254    env!("CARGO_PKG_VERSION").to_string()
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn trusted_url_skips_the_script_url_check() {
263        assert_eq!(
264            WElement::new("a")
265                .trusted_url("href", "javascript:print()")
266                .render(),
267            "<a href=\"javascript:print()\"></a>"
268        );
269        assert_eq!(
270            WElement::new("a").attr("href", "javascript:x").render(),
271            "<a href=\"#\"></a>"
272        );
273    }
274}