Skip to main content

xdoc/component/
mod.rs

1//! Leptos-style XML component model.
2//!
3//! Components are plain Rust functions. They receive typed props and optional
4//! `Children`, then return any value implementing `IntoXml` or
5//! `IntoXmlFragment`. Reactivity is intentionally outside the MVP.
6//! Typed props are regular Rust structs owned by the component function.
7
8use crate::builder::{DocumentBuilder, ElementBuilder};
9use crate::core::{Document, XmlResult};
10
11pub use crate::builder::{
12    fragment, FragmentBuilder as Fragment, IntoXmlFragment, XmlNode as FragmentNode,
13};
14
15/// Explicit children input for component functions.
16pub type Children = Fragment;
17
18/// Converts a value into a single XML element builder.
19pub trait IntoXml {
20    fn into_xml(self) -> XmlResult<ElementBuilder>;
21}
22
23impl IntoXml for ElementBuilder {
24    fn into_xml(self) -> XmlResult<ElementBuilder> {
25        Ok(self)
26    }
27}
28
29impl IntoXml for XmlResult<ElementBuilder> {
30    fn into_xml(self) -> XmlResult<ElementBuilder> {
31        self
32    }
33}
34
35/// Builds a document from a component that returns one root element.
36pub fn document(component: impl IntoXml) -> XmlResult<Document> {
37    DocumentBuilder::new().root(component.into_xml()?)?.build()
38}
39
40/// Normalizes any fragment-like value into an explicit `Children` value.
41pub fn children(value: impl IntoXmlFragment) -> XmlResult<Children> {
42    value.into_xml_fragment()
43}
44
45#[cfg(test)]
46mod tests {
47    use super::*;
48    use crate::builder::{element, text};
49    use crate::core::ErrorKind;
50    use crate::writer::{to_string_compact, to_string_pretty, WriterConfig};
51
52    fn simple_component() -> XmlResult<ElementBuilder> {
53        element("Simple")?.text("value")
54    }
55
56    fn wrapper(children: Children) -> XmlResult<ElementBuilder> {
57        element("Wrapper")?.child(children)
58    }
59
60    fn layout(title: impl IntoXml, children: Children) -> XmlResult<ElementBuilder> {
61        element("Layout")?.child(title.into_xml()?)?.child(children)
62    }
63
64    fn pair_component() -> XmlResult<Fragment> {
65        fragment()
66            .child(element("First")?.text("one")?)?
67            .child(element("Second")?.text("two")?)
68    }
69
70    fn item_list_from_vec(items: Vec<ItemProps>) -> XmlResult<ElementBuilder> {
71        element("Items")?.child(
72            items
73                .into_iter()
74                .map(item_component)
75                .collect::<Vec<XmlResult<ElementBuilder>>>(),
76        )
77    }
78
79    #[derive(Debug, Clone)]
80    struct IdProps {
81        value: String,
82    }
83
84    fn id_component(props: IdProps) -> XmlResult<ElementBuilder> {
85        element("ID")?.text(props.value)
86    }
87
88    #[derive(Debug, Clone)]
89    struct ItemProps {
90        code: String,
91        name: String,
92        quantity: u32,
93    }
94
95    fn item_component(props: ItemProps) -> XmlResult<ElementBuilder> {
96        element("Item")?
97            .attr("code", props.code)?
98            .attr("quantity", props.quantity)?
99            .text(props.name)
100    }
101
102    #[derive(Debug, Clone)]
103    struct QualifiedElementProps {
104        prefix: String,
105        local_name: String,
106        namespace_uri: String,
107        text: String,
108    }
109
110    fn qualified_component(props: QualifiedElementProps) -> XmlResult<ElementBuilder> {
111        ElementBuilder::qualified(props.prefix, props.local_name, props.namespace_uri)?
112            .text(props.text)
113    }
114
115    #[test]
116    fn component_contracts_into_xml_accepts_element_builder() -> XmlResult<()> {
117        let root = element("Root")?.text("ok")?.into_xml()?;
118        let document = document(root)?;
119
120        assert_eq!(to_string_compact(&document)?, "<Root>ok</Root>");
121        Ok(())
122    }
123
124    #[test]
125    fn into_xml_accepts_component_result() -> XmlResult<()> {
126        let document = document(simple_component())?;
127
128        assert_eq!(to_string_compact(&document)?, "<Simple>value</Simple>");
129        Ok(())
130    }
131
132    #[test]
133    fn component_contracts_into_xml_fragment_is_available() -> XmlResult<()> {
134        let fragment = text("hello").into_xml_fragment()?;
135        let document = document(element("Root")?.child(fragment)?)?;
136
137        assert_eq!(to_string_compact(&document)?, "<Root>hello</Root>");
138        Ok(())
139    }
140
141    #[test]
142    fn component_contracts_children_are_explicit_fragments() -> XmlResult<()> {
143        let children = children(fragment().child(element("Child")?.text("value")?)?)?;
144        let document = document(wrapper(children)?)?;
145
146        assert_eq!(
147            to_string_compact(&document)?,
148            "<Wrapper><Child>value</Child></Wrapper>"
149        );
150        Ok(())
151    }
152
153    #[test]
154    fn component_contracts_materialize_with_builder_backend() -> XmlResult<()> {
155        let root = element("Root")?.child(simple_component())?;
156        let document = DocumentBuilder::new().root(root)?.build()?;
157
158        assert_eq!(
159            to_string_compact(&document)?,
160            "<Root><Simple>value</Simple></Root>"
161        );
162        Ok(())
163    }
164
165    #[test]
166    fn component_props_create_xml_with_typed_props() -> XmlResult<()> {
167        let props = ItemProps {
168            code: "A1".to_owned(),
169            name: "Widget".to_owned(),
170            quantity: 3,
171        };
172        let document = document(item_component(props)?)?;
173
174        assert_eq!(
175            to_string_compact(&document)?,
176            "<Item code=\"A1\" quantity=\"3\">Widget</Item>"
177        );
178        Ok(())
179    }
180
181    #[test]
182    fn component_props_can_build_text_from_props() -> XmlResult<()> {
183        let props = IdProps {
184            value: "INV-1".to_owned(),
185        };
186        let document = document(id_component(props)?)?;
187
188        assert_eq!(to_string_compact(&document)?, "<ID>INV-1</ID>");
189        Ok(())
190    }
191
192    #[test]
193    fn component_props_can_build_qualified_names() -> XmlResult<()> {
194        let props = QualifiedElementProps {
195            prefix: "doc".to_owned(),
196            local_name: "Title".to_owned(),
197            namespace_uri: "urn:doc".to_owned(),
198            text: "Report".to_owned(),
199        };
200        let document = document(qualified_component(props)?)?;
201
202        assert_eq!(
203            to_string_compact(&document)?,
204            "<doc:Title>Report</doc:Title>"
205        );
206        Ok(())
207    }
208
209    #[test]
210    fn component_props_invalid_names_return_xml_error() {
211        let props = QualifiedElementProps {
212            prefix: "doc".to_owned(),
213            local_name: "1Invalid".to_owned(),
214            namespace_uri: "urn:doc".to_owned(),
215            text: "Report".to_owned(),
216        };
217        let error = qualified_component(props).expect_err("invalid local name must fail");
218
219        assert_eq!(error.kind(), &ErrorKind::InvalidName);
220    }
221
222    #[test]
223    fn children_component_wraps_explicit_children() -> XmlResult<()> {
224        let content = children(
225            fragment()
226                .child(element("A")?.text("one")?)?
227                .child(element("B")?.text("two")?)?,
228        )?;
229        let document = document(wrapper(content)?)?;
230
231        assert_eq!(
232            to_string_compact(&document)?,
233            "<Wrapper><A>one</A><B>two</B></Wrapper>"
234        );
235        Ok(())
236    }
237
238    #[test]
239    fn children_fragment_component_returns_multiple_nodes() -> XmlResult<()> {
240        let document = document(element("Root")?.child(pair_component()?)?)?;
241
242        assert_eq!(
243            to_string_compact(&document)?,
244            "<Root><First>one</First><Second>two</Second></Root>"
245        );
246        Ok(())
247    }
248
249    #[test]
250    fn children_components_compose_from_plain_rust() -> XmlResult<()> {
251        let title = id_component(IdProps {
252            value: "T-1".to_owned(),
253        })?;
254        let content = children(pair_component()?)?;
255        let document = document(layout(title, content)?)?;
256
257        assert_eq!(
258            to_string_compact(&document)?,
259            "<Layout><ID>T-1</ID><First>one</First><Second>two</Second></Layout>"
260        );
261        Ok(())
262    }
263
264    #[test]
265    fn component_collections_option_can_include_content() -> XmlResult<()> {
266        let include = true;
267        let optional_child = include.then(simple_component);
268        let document = document(element("Root")?.child(optional_child.transpose()?)?)?;
269
270        assert_eq!(
271            to_string_compact(&document)?,
272            "<Root><Simple>value</Simple></Root>"
273        );
274        Ok(())
275    }
276
277    #[test]
278    fn component_collections_option_can_omit_content() -> XmlResult<()> {
279        let include = false;
280        let optional_child = include.then(simple_component);
281        let document = document(element("Root")?.child(optional_child.transpose()?)?)?;
282
283        assert_eq!(to_string_compact(&document)?, "<Root/>");
284        Ok(())
285    }
286
287    #[test]
288    fn component_collections_vec_represents_list_nodes() -> XmlResult<()> {
289        let items = vec![
290            ItemProps {
291                code: "a".to_owned(),
292                name: "Alpha".to_owned(),
293                quantity: 1,
294            },
295            ItemProps {
296                code: "b".to_owned(),
297                name: "Beta".to_owned(),
298                quantity: 2,
299            },
300        ];
301        let document = document(item_list_from_vec(items)?)?;
302
303        assert_eq!(
304            to_string_compact(&document)?,
305            "<Items><Item code=\"a\" quantity=\"1\">Alpha</Item><Item code=\"b\" quantity=\"2\">Beta</Item></Items>"
306        );
307        Ok(())
308    }
309
310    #[test]
311    fn component_collections_iterators_preserve_order() -> XmlResult<()> {
312        let document = document(
313            element("Items")?.children(
314                ["a", "b", "c"]
315                    .into_iter()
316                    .map(|code| element("Item")?.attr("code", code)?.text(code)),
317            )?,
318        )?;
319
320        assert_eq!(
321            to_string_compact(&document)?,
322            "<Items><Item code=\"a\">a</Item><Item code=\"b\">b</Item><Item code=\"c\">c</Item></Items>"
323        );
324        Ok(())
325    }
326
327    #[test]
328    fn component_writer_serializes_simple_component_compact() -> XmlResult<()> {
329        let document = document(simple_component())?;
330
331        assert_eq!(to_string_compact(&document)?, "<Simple>value</Simple>");
332        Ok(())
333    }
334
335    #[test]
336    fn component_writer_serializes_props_component_compact() -> XmlResult<()> {
337        let document = document(item_component(ItemProps {
338            code: "x".to_owned(),
339            name: "Xray".to_owned(),
340            quantity: 7,
341        })?)?;
342
343        assert_eq!(
344            to_string_compact(&document)?,
345            "<Item code=\"x\" quantity=\"7\">Xray</Item>"
346        );
347        Ok(())
348    }
349
350    #[test]
351    fn component_writer_serializes_children_component_compact() -> XmlResult<()> {
352        let content = children(pair_component()?)?;
353        let document = document(wrapper(content)?)?;
354
355        assert_eq!(
356            to_string_compact(&document)?,
357            "<Wrapper><First>one</First><Second>two</Second></Wrapper>"
358        );
359        Ok(())
360    }
361
362    #[test]
363    fn component_writer_serializes_list_in_stable_order() -> XmlResult<()> {
364        let document = document(
365            element("Items")?.children(
366                ["c", "a", "b"]
367                    .into_iter()
368                    .map(|code| element("Item")?.attr("code", code)?.text(code)),
369            )?,
370        )?;
371
372        let first = to_string_compact(&document)?;
373        let second = to_string_compact(&document)?;
374
375        assert_eq!(first, second);
376        assert_eq!(
377            first,
378            "<Items><Item code=\"c\">c</Item><Item code=\"a\">a</Item><Item code=\"b\">b</Item></Items>"
379        );
380        Ok(())
381    }
382
383    #[test]
384    fn component_writer_pretty_output_is_stable() -> XmlResult<()> {
385        let content = children(pair_component()?)?;
386        let document = document(wrapper(content)?)?;
387
388        let xml = to_string_pretty(&document, WriterConfig::pretty())?;
389
390        assert_eq!(
391            xml,
392            "<Wrapper>\n  <First>one</First>\n  <Second>two</Second>\n</Wrapper>"
393        );
394        Ok(())
395    }
396}