Skip to main content

ftml/render/html/
builder.rs

1/*
2 * render/html/builder.rs
3 *
4 * ftml - Library to parse Wikidot text
5 * Copyright (C) 2019-2026 Wikijump Team
6 *
7 * This program is free software: you can redistribute it and/or modify
8 * it under the terms of the GNU Affero General Public License as published by
9 * the Free Software Foundation, either version 3 of the License, or
10 * (at your option) any later version.
11 *
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU Affero General Public License for more details.
16 *
17 * You should have received a copy of the GNU Affero General Public License
18 * along with this program. If not, see <http://www.gnu.org/licenses/>.
19 */
20
21use super::attributes::AddedAttributes;
22use super::context::HtmlContext;
23use super::render::ItemRender;
24use std::collections::HashSet;
25
26macro_rules! tag_method {
27    ($tag:tt) => {
28        pub fn $tag(self) -> HtmlBuilderTag<'c, 'i, 'h, 'e, 't> {
29            self.tag(stringify!($tag))
30        }
31    };
32}
33
34/// These are HTML tags which do not need a closing pair.
35const SOLO_HTML_TAGS: [&str; 14] = [
36    "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param",
37    "source", "track", "wbr",
38];
39
40// Main struct
41
42#[derive(Debug)]
43pub struct HtmlBuilder<'c, 'i, 'h, 'e, 't>
44where
45    'e: 't,
46{
47    ctx: &'c mut HtmlContext<'i, 'h, 'e, 't>,
48}
49
50impl<'c, 'i, 'h, 'e, 't> HtmlBuilder<'c, 'i, 'h, 'e, 't>
51where
52    'e: 't,
53{
54    #[inline]
55    pub fn new(ctx: &'c mut HtmlContext<'i, 'h, 'e, 't>) -> Self {
56        HtmlBuilder { ctx }
57    }
58
59    /// Create a new HTML element with the given tag type.
60    #[inline]
61    pub fn tag(self, tag: &'t str) -> HtmlBuilderTag<'c, 'i, 'h, 'e, 't> {
62        debug_assert!(is_alphanumeric(tag));
63
64        let HtmlBuilder { ctx } = self;
65        HtmlBuilderTag::new(ctx, tag)
66    }
67
68    /// Create a new custom element. Tag must start with `wj-`.
69    #[inline]
70    pub fn element(self, tag: &'t str) -> HtmlBuilderTag<'c, 'i, 'h, 'e, 't> {
71        debug_assert!(tag.starts_with("wj-"));
72
73        self.tag(tag)
74    }
75
76    #[inline]
77    pub fn table_cell(self, header: bool) -> HtmlBuilderTag<'c, 'i, 'h, 'e, 't> {
78        if header {
79            self.tag("th")
80        } else {
81            self.tag("td")
82        }
83    }
84
85    /// Creates an inline `<svg>` using the `ui.svg` spritesheet.
86    pub fn sprite(self, id: &'t str) {
87        let viewbox = match id {
88            "wj-karma" => "0 0 64 114",
89            _ => "0 0 24 24",
90        };
91
92        let class = format!("wj-sprite sprite-{id}");
93        let href = format!("/files--static/media/ui.svg#{id}");
94
95        self.tag("svg")
96            .attr(attr!(
97                "class" => &class,
98                "viewBox" => viewbox,
99            ))
100            .inner(|ctx| {
101                ctx.html().tag("use").attr(attr!("href" => &href));
102            });
103    }
104
105    tag_method!(a);
106    tag_method!(article);
107    tag_method!(br);
108    tag_method!(code);
109    tag_method!(dd);
110    tag_method!(details);
111    tag_method!(div);
112    tag_method!(dl);
113    tag_method!(dt);
114    tag_method!(em);
115    tag_method!(hr);
116    tag_method!(iframe);
117    tag_method!(img);
118    tag_method!(input);
119    tag_method!(li);
120    tag_method!(nav);
121    tag_method!(ol);
122    tag_method!(pre);
123    tag_method!(rp);
124    tag_method!(script);
125    tag_method!(style);
126    tag_method!(span);
127    tag_method!(sub);
128    tag_method!(sup);
129    tag_method!(summary);
130    tag_method!(table);
131    tag_method!(tbody);
132    tag_method!(tr);
133    tag_method!(ul);
134
135    #[inline]
136    pub fn text(&mut self, text: &str) {
137        self.ctx.push_escaped(text);
138    }
139}
140
141// Helper structs
142
143#[derive(Debug)]
144pub struct HtmlBuilderTag<'c, 'i, 'h, 'e, 't>
145where
146    'e: 't,
147{
148    ctx: &'c mut HtmlContext<'i, 'h, 'e, 't>,
149    tag: &'t str,
150    in_tag: bool,
151    in_contents: bool,
152}
153
154impl<'c, 'i, 'h, 'e, 't> HtmlBuilderTag<'c, 'i, 'h, 'e, 't> {
155    pub fn new(ctx: &'c mut HtmlContext<'i, 'h, 'e, 't>, tag: &'t str) -> Self {
156        ctx.push_raw('<');
157        ctx.push_raw_str(tag);
158
159        HtmlBuilderTag {
160            ctx,
161            tag,
162            in_tag: true,
163            in_contents: false,
164        }
165    }
166
167    fn attr_key(&mut self, key: &str, has_value: bool) {
168        debug_assert!(is_alphanumeric(key));
169        debug_assert!(self.in_tag);
170
171        self.ctx.push_raw(' ');
172        self.ctx.push_escaped(key);
173
174        if has_value {
175            self.ctx.push_raw('=');
176        }
177    }
178
179    fn attr_value(&mut self, value_parts: &[&str]) {
180        self.ctx.push_raw('"');
181
182        for part in value_parts {
183            self.ctx.push_escaped(part);
184        }
185
186        self.ctx.push_raw('"');
187    }
188
189    pub fn attr_single(&mut self, key: &str, value_parts: &[&str]) -> &mut Self {
190        // If value_parts is empty, then we just give the key.
191        //
192        // For instance, ("checked", &[]) in input produces
193        // <input checked> rather than <input checked="...">
194        //
195        // Alternatively, if it's only composed of empty strings,
196        // the same intent is signalled.
197        //
198        // Because .all() is true for empty slices, this expression
199        // checks both:
200
201        let has_value = !value_parts.iter().all(|s| s.is_empty());
202
203        self.attr_key(key, has_value);
204
205        if has_value {
206            self.attr_value(value_parts);
207        }
208
209        self
210    }
211
212    pub fn attr(&mut self, attributes: AddedAttributes) -> &mut Self {
213        fn filter_entries<'a>(
214            attributes: &AddedAttributes<'a>,
215        ) -> impl Iterator<Item = (&'a str, &'a [&'a str])> {
216            attributes.entries.iter().filter_map(
217                |(item, accept)| {
218                    if *accept { Some(*item) } else { None }
219                },
220            )
221        }
222
223        let mut merged = HashSet::new();
224        let mut merged_value = Vec::new();
225
226        // Merge any attributes in common.
227        if let Some(attribute_map) = attributes.map {
228            let attribute_map = attribute_map.get();
229
230            for (key, value_parts) in filter_entries(&attributes) {
231                if let Some(map_value) = attribute_map.get(&cow!(key)) {
232                    // Merge keys by prepending value_parts before
233                    // the attribute map value.
234
235                    merged_value.clear();
236                    merged_value.extend(value_parts);
237                    merged_value.push(" ");
238                    merged_value.push(map_value);
239
240                    self.attr_single(key, &merged_value);
241                    merged.insert(key);
242                }
243            }
244        }
245
246        // Add attributes from renderer.
247        for (key, value_parts) in filter_entries(&attributes) {
248            if !merged.contains(key) {
249                self.attr_single(key, value_parts);
250            }
251        }
252
253        // Add attributes from user-provided map.
254        if let Some(attribute_map) = attributes.map {
255            for (key, value) in attribute_map.get() {
256                if !merged.contains(key.as_ref()) {
257                    self.attr_single(key, &[value]);
258                }
259            }
260        }
261
262        self
263    }
264
265    fn content_start(&mut self) {
266        if self.in_tag {
267            self.ctx.push_raw('>');
268            self.in_tag = false;
269        }
270
271        assert!(!self.in_contents, "Already in tag contents");
272        self.in_contents = true;
273    }
274
275    #[inline]
276    pub fn contents<R: ItemRender>(&mut self, item: R) -> &mut Self {
277        self.content_start();
278        item.render(self.ctx);
279        self
280    }
281
282    pub fn inner<F>(&mut self, mut f: F) -> &mut Self
283    where
284        F: FnMut(&mut HtmlContext),
285    {
286        self.content_start();
287        f(self.ctx);
288        self
289    }
290}
291
292impl Drop for HtmlBuilderTag<'_, '_, '_, '_, '_> {
293    fn drop(&mut self) {
294        if self.in_tag && !self.in_contents {
295            self.ctx.push_raw('>');
296        }
297
298        if should_close_tag(self.tag) {
299            self.ctx.push_raw_str("</");
300            self.ctx.push_raw_str(self.tag);
301            self.ctx.push_raw('>');
302        }
303    }
304}
305
306// Helpers
307
308fn is_alphanumeric(value: &str) -> bool {
309    value
310        .chars()
311        .all(|c| c.is_ascii_alphabetic() || c.is_ascii_digit() || c == '-')
312}
313
314#[inline]
315fn should_close_tag(tag: &str) -> bool {
316    !SOLO_HTML_TAGS.contains(&tag)
317}