Skip to main content

winged_rust/
wasm.rs

1//! WebAssembly bindings.
2//!
3//! # Why this does not follow `WINGED_RUST_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 the
12//! Node smoke test diff its output against the same golden fixture the Rust tests use. If
13//! native and WASM ever diverge, that diff 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    #[must_use]
66    pub fn attr(self, key: &str, value: &str) -> Self {
67        Self(self.0.attr(key, value))
68    }
69
70    /// Appends a boolean attribute, which renders as a bare key.
71    #[wasm_bindgen(js_name = boolAttr)]
72    #[must_use]
73    pub fn bool_attr(self, key: &str) -> Self {
74        Self(self.0.bool_attr(key))
75    }
76
77    /// Appends `data-{key}="{value}"`.
78    #[wasm_bindgen(js_name = dataAttr)]
79    #[must_use]
80    pub fn data_attr(self, key: &str, value: &str) -> Self {
81        Self(self.0.data_attr(key, value))
82    }
83
84    /// Appends `aria-{key}="{value}"`.
85    #[wasm_bindgen(js_name = ariaAttr)]
86    #[must_use]
87    pub fn aria_attr(self, key: &str, value: &str) -> Self {
88        Self(self.0.aria_attr(key, value))
89    }
90
91    /// Sets the text content, escaping it.
92    #[must_use]
93    pub fn text(self, content: &str) -> Self {
94        Self(self.0.text(content))
95    }
96
97    /// Sets the content **without** escaping it.
98    #[wasm_bindgen(js_name = rawText)]
99    #[must_use]
100    pub fn raw_text(self, content: &str) -> Self {
101        Self(self.0.raw_text(content))
102    }
103
104    /// Appends a child element.
105    #[must_use]
106    pub fn child(self, child: &WElement) -> Self {
107        Self(self.0.child(child.0.clone()))
108    }
109
110    /// Appends an HTML comment as a child.
111    #[must_use]
112    pub fn comment(self, content: &str) -> Self {
113        Self(self.0.child(Node::comment(content)))
114    }
115
116    /// Renders on a single line.
117    #[must_use]
118    pub fn render(&self) -> String {
119        self.0.render()
120    }
121
122    /// Renders with indentation.
123    #[wasm_bindgen(js_name = renderPretty)]
124    #[must_use]
125    pub fn render_pretty(&self) -> String {
126        self.0.render_pretty()
127    }
128}
129
130/// A complete HTML document, usable from JavaScript.
131#[wasm_bindgen(js_name = WDocument)]
132#[derive(Debug, Clone)]
133pub struct WDocument(Document);
134
135#[wasm_bindgen(js_class = WDocument)]
136impl WDocument {
137    /// Creates a document. Pass `null` for `lang` to omit the attribute.
138    // `Option<String>` rather than `Option<&str>`: wasm-bindgen cannot marshal an optional
139    // borrowed string across the boundary, so the owned form is the only one that compiles.
140    #[allow(clippy::needless_pass_by_value)]
141    #[wasm_bindgen(constructor)]
142    #[must_use]
143    pub fn new(lang: Option<String>) -> Self {
144        Self(Document::new(lang.as_deref()))
145    }
146
147    /// Appends an element to the `<head>`.
148    #[wasm_bindgen(js_name = addHead)]
149    #[must_use]
150    pub fn add_head(self, element: &WElement) -> Self {
151        Self(self.0.head_children([element.0.clone()]))
152    }
153
154    /// Appends an element to the `<body>`.
155    #[wasm_bindgen(js_name = addBody)]
156    #[must_use]
157    pub fn add_body(self, element: &WElement) -> Self {
158        Self(self.0.body_children([element.0.clone()]))
159    }
160
161    /// Renders the document, pretty-printed and prefixed by the doctype.
162    #[must_use]
163    pub fn render(&self) -> String {
164        self.0.render()
165    }
166
167    /// Renders the document on a single line.
168    #[wasm_bindgen(js_name = renderCompact)]
169    #[must_use]
170    pub fn render_compact(&self) -> String {
171        self.0.render_compact()
172    }
173
174    /// Renders with a custom indent string.
175    #[wasm_bindgen(js_name = renderWithIndent)]
176    #[must_use]
177    pub fn render_with_indent(&self, indent: &str) -> String {
178        self.0
179            .render_with(&RenderOptions::pretty().with_indent(indent))
180    }
181}
182
183/// A page's SEO metadata block, usable from JavaScript.
184#[wasm_bindgen(js_name = WSeo)]
185#[derive(Debug, Clone)]
186pub struct WSeo(SeoBuilder);
187
188#[wasm_bindgen(js_class = WSeo)]
189impl WSeo {
190    /// Starts a metadata block.
191    #[wasm_bindgen(constructor)]
192    #[must_use]
193    pub fn new(title: &str, description: &str) -> Self {
194        Self(SeoBuilder::new(title, description))
195    }
196
197    /// Sets the preview image.
198    #[must_use]
199    pub fn image(self, image_url: &str) -> Self {
200        Self(self.0.image(image_url))
201    }
202
203    /// Sets the canonical URL.
204    #[must_use]
205    pub fn url(self, page_url: &str) -> Self {
206        Self(self.0.url(page_url))
207    }
208
209    /// Sets `twitter:site`.
210    #[wasm_bindgen(js_name = twitterSite)]
211    #[must_use]
212    pub fn twitter_site(self, site: &str) -> Self {
213        Self(self.0.twitter_site(site))
214    }
215
216    /// Renders the whole metadata block as markup.
217    #[must_use]
218    pub fn render(&self) -> String {
219        self.0.build().iter().map(Render::render).collect()
220    }
221}
222
223/// Creates an element. The idiomatic JS entry point — `element("div")` reads better than
224/// `new WElement("div")`.
225#[wasm_bindgen]
226#[must_use]
227pub fn element(tag: &str) -> WElement {
228    WElement::new(tag)
229}
230
231/// Escapes text for HTML content, exposed for callers doing their own assembly.
232#[wasm_bindgen(js_name = escapeText)]
233#[must_use]
234pub fn escape_text(input: &str) -> String {
235    crate::core::escape::escape_text(input)
236}
237
238/// The crate version, so a page can report which build produced it.
239#[wasm_bindgen]
240#[must_use]
241pub fn version() -> String {
242    env!("CARGO_PKG_VERSION").to_string()
243}