htmeta/
lib.rs

1#![feature(let_chains)]
2#![doc = include_str!("../README.md")]
3
4macro_rules! re {
5    ($name:ident, $e:expr) => {
6        use regex::Regex;
7        use std::sync::LazyLock;
8        static $name: LazyLock<Regex> = LazyLock::new(|| Regex::new($e).unwrap());
9    };
10}
11
12use std::{
13    borrow::Cow,
14    collections::HashMap,
15    io::Write
16};
17
18pub use kdl;
19
20use kdl::{KdlDocument, KdlNode, KdlValue};
21use regex::Captures;
22
23/// Information that plugins can use to change what is being emitted.
24///
25/// Check out [`HtmlEmitter`] for more information!
26pub struct PluginContext<'a, 'b: 'a> {
27    /// Pre-computed indentation from the current level.
28    pub indent: &'a str,
29    /// The [`Writer`] handle we're currently emitting into.
30    pub writer: &'a mut Writer<'b>,
31    /// A handle to the current node's emitter.
32    pub emitter: &'a HtmlEmitter<'a>
33}
34
35/// A trait that allows you to hook into `htmeta`'s emitter and extend it!
36pub trait IPlugin {
37    fn dyn_clone(&self) -> Box<dyn IPlugin>;
38    fn emit_node(&self, node: &KdlNode, context: PluginContext) -> EmitResult<bool>;
39}
40
41/// Convenient alias for a [`std::io::Write`] mutable reference.
42pub type Writer<'a> = &'a mut dyn Write;
43
44/// Convenient alias for this crate's return types.
45pub type EmitResult<T = ()> = Result<T, Error>;
46
47/// The type used to represent indentation length.
48///
49/// Could change in the future to be more efficient, so please,
50/// use this instead of the type it is aliasing!
51pub type Indent = usize;
52
53type Text<'b> = Cow<'b, str>;
54
55struct Plugin(Box<dyn IPlugin>);
56
57impl Plugin {
58    pub fn new<P: IPlugin + 'static>(plugin: P) -> Self {
59        Self(Box::new(plugin))
60    }
61}
62
63impl Clone for Plugin {
64    fn clone(&self) -> Self {
65        Self(self.0.dyn_clone())
66    }
67}
68
69mod error;
70
71pub use error::Error;
72
73const VOID_TAGS: &[&str] = &[
74    "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
75    "track", "wbr",
76    "!DOCTYPE" // not a tag at all, but works a lot like one.
77];
78
79/// A builder for [`HtmlEmitter`]s.
80#[derive(Clone, Default)]
81pub struct HtmlEmitterBuilder {
82    indent: Indent,
83    plugins: Vec<Plugin>,
84}
85
86impl HtmlEmitterBuilder {
87    /// Returns a new [`Self`] instance with a default indentation value of 4.
88    pub fn new() -> Self {
89        Self { indent: 4, ..Self::default() }
90    }
91
92    /// Sets the indentation amount. Implies pretty formatting.
93    pub fn indent(&mut self, indent: Indent) -> &mut Self {
94        self.indent = indent;
95        self
96    }
97
98    /// Disables indentation and newlines. 
99    pub fn minify(&mut self) -> &mut Self {
100        self.indent = 0;
101        self
102    }
103
104    /// Registers a plugin for all instances of this builder.
105    pub fn add_plugin<P: IPlugin + 'static>(&mut self, plugin: P) -> &mut Self {
106        self.plugins.push(Plugin::new(plugin));
107        self
108    }
109
110    /// Creates a new [`HtmlEmitter`]. You should re-use this builder to create emitters
111    /// efficiently.
112    pub fn build<'a>(&self) -> HtmlEmitter<'a> {
113        HtmlEmitter {
114            current_level: 0,
115            indent: self.indent,
116            plugins: self.plugins.clone(),
117            vars: Default::default(),
118        }
119    }
120
121    /* pub fn sex(&self, text: &str) -> HtmlEmitter<'> {
122        
123    } */
124}
125
126/// The `HTML` emitter for `htmeta`.
127///
128/// ```rust
129/// use htmeta::HtmlEmitter;
130/// use kdl::KdlDocument;
131/// let doc: KdlDocument = r#"
132/// html {
133///     body {
134///         h1 {
135///             text "Title"
136///         }
137///     }
138/// }"#.parse().unwrap();
139///
140/// // Creates an emitter with an indentation level of 4.
141/// let mut emitter = HtmlEmitter::builder().indent(4).build();
142///
143/// // Emits html to the terminal.
144/// emitter.emit(&doc, &mut std::io::stdout()).unwrap();
145/// ```
146#[derive(Clone)]
147pub struct HtmlEmitter<'a> {
148    pub indent: Indent,
149    pub current_level: Indent,
150    pub vars: HashMap<&'a str, Text<'a>>,
151    plugins: Vec<Plugin>,
152}
153
154impl<'a> HtmlEmitter<'a> {
155    /// A convenience method that just calls [`HtmlEmitterBuilder::new`].
156    ///
157    /// Check out that type's documentation for uses!
158    pub fn builder() -> HtmlEmitterBuilder {
159        HtmlEmitterBuilder::new()
160    }
161
162    /// Returns an [`HtmlEmitter`] with a copy of `self`'s variables and one indentation level
163    /// deeper. This emitter should be used to translate a child of `self`.
164    pub fn subemitter(&self) -> Self {
165        Self {
166            current_level: self.current_level + 1,
167            // node,
168            ..self.clone()
169        }
170    }
171
172    /// Returns `true` if in pretty mode, `false` otherwise.
173    pub fn is_pretty(&self) -> bool {
174        self.indent > 0
175    }
176
177    /// Convenience function that writes a newline if in pretty mode.
178    pub fn write_line(&self, writer: Writer) -> EmitResult {
179        if self.is_pretty() {
180            writeln!(writer)?;
181        }
182        Ok(())
183    }
184
185    /// Convenience function that returns a new [`String`] containing the current indentation
186    /// level's worth of spaces.
187    ///
188    /// # Example
189    /// ```rust no_test
190    /// use htmeta::HtmlEmitter;
191    /// let emitter = HtmlEmitter::builder().indent(4).build();
192    /// assert_eq!(emitter.indent(), "");
193    /// ```
194    pub fn indent(&self) -> String {
195        " ".repeat(self.current_level * self.indent)
196    }
197
198    /// Replaces all occurences of variables inside `text` and returns a new string.
199    pub fn expand_string<'b>(&self, text: &'b str) -> Text<'b> {
200        re!(VAR, r"\$(\w+)");
201        VAR.replace(text, |captures: &Captures| {
202            self.vars
203                .get(&captures[1])
204                .map(ToString::to_string)
205                .unwrap_or_default()
206        })
207    }
208
209    /// Converts the `value`'s [`String`] representation and replaces any variables found within.
210    /// This is a convenient wrapper around [`Self::expand_string`].
211    pub fn expand_value<'b>(&self, value: &'b KdlValue) -> Text<'b> {
212        match value {
213            KdlValue::RawString(content) | KdlValue::String(content) => self.expand_string(content),
214            _ => todo!(),
215        }
216    }
217
218    /// Emits a compound `HTML` tag named `name`, with `indent` as indentation, using `node` for
219    /// properties and children.
220    pub fn emit_tag(
221        &self,
222        node: &KdlNode,
223        name: &str,
224        indent: &str,
225        writer: Writer
226    ) -> EmitResult {
227        let is_void = VOID_TAGS.contains(&name);
228
229        // opening tag
230        write!(writer, "{}<{}", indent, name)?;
231        // args
232        let args = node
233            .entries()
234            .iter()
235            .map(|arg| self.expand_string(&arg.to_string()).into_owned())
236            .collect::<Vec<_>>()
237            .join("");
238
239        write!(writer, "{}", args)?;
240
241        if is_void {
242            write!(writer, ">")?;
243            self.write_line(writer)?;
244        } else {
245            write!(writer, ">")?;
246            // Children
247            if let Some(doc) = node.children() {
248                self.write_line(writer)?;
249                self.subemitter().emit(doc, writer)?;
250                write!(writer, "{}", indent)?;
251            }
252            write!(writer, "</{}>", name)?;
253            self.write_line(writer)?;
254        }
255        Ok(())
256    }
257
258    fn call_plugin<'b: 'a>(&'b self, node: &'a KdlNode, indent: &'b str, mut writer: Writer<'b>) -> EmitResult<bool> {
259        for plug in &self.plugins {
260            let ctx = PluginContext {
261                indent, 
262                emitter: self,
263                writer: &mut writer,
264            };
265            if plug.0.emit_node(node, ctx)? {
266                return Ok(true)
267            }
268        }
269        Ok(false)
270    }
271
272    /// Simply emits the given text content in `content` into the `writer`, indented by the
273    /// `indent` param.
274    ///
275    /// # Example
276    /// ```
277    /// use kdl::KdlValue;
278    /// use htmeta::HtmlEmitter;
279    /// let emitter = HtmlEmitter::builder().indent(4).build();
280    /// let mut writer = Vec::<u8>::new();
281    /// // Usually this value is given to you by other functions.
282    /// let indent = emitter.indent();
283    /// let value = KdlValue::String("I'm text".into());
284    /// emitter.emit_text_node(&indent, &value, &mut writer).unwrap();
285    /// assert_eq!(writer, b"I'm text\n");
286    /// ```
287    pub fn emit_text_node(&self, indent: &str, content: &KdlValue, writer: Writer) -> EmitResult {
288        write!(
289            writer,
290            "{}{}",
291            indent,
292            html_escape::encode_text(&self.expand_value(content))
293        )?;
294        self.write_line(writer)?;
295        Ok(())
296    }
297
298    /// Emits the corresponding `HTML` into the `writer`. The emitter can be re-used after this.
299    ///
300    /// # Examples:
301    ///
302    /// ```rust
303    /// use htmeta::HtmlEmitter;
304    /// use kdl::KdlDocument;
305    /// let doc: KdlDocument = r#"
306    ///     html {
307    ///         body {
308    ///             h1 {
309    ///                 text "Title"
310    ///             }
311    ///         }
312    ///     }"#.parse().unwrap();
313    /// // Creates an emitter with an indentation level of 4.
314    /// let mut emitter = HtmlEmitter::builder().indent(4).build();
315    /// // You should wrap this with a `BufWriter` for actual use.
316    /// let mut file = std::fs::File::create("index.html").unwrap();
317    /// emitter.emit(&doc, &mut file).unwrap();
318    /// ```
319    pub fn emit<'b: 'a>(&mut self, document: &'b KdlDocument, writer: Writer) -> EmitResult {
320        let indent = self.indent();
321
322        for node in document.nodes() {
323            let name = node.name().value();
324
325            // variable node
326            if name.starts_with("$")
327                && let Some(val) = node.get(0)
328            {
329                self.vars.insert(&name[1..], self.expand_value(val.value()));
330                continue;
331            }
332
333            // text node
334            if name == "text"
335                && let Some(content) = node.get(0)
336            {
337                self.emit_text_node(&indent, content.value(), writer)?;
338                continue;
339            }
340
341            // Plugin shenanigans
342            if self.call_plugin(node, &indent, writer)? {
343                continue
344            }
345
346            // Compound node, AKA, normal HTML tag.
347            self.emit_tag(node, name, &indent, writer)?
348        }
349        // Allows this instance to be reused
350        self.vars.clear();
351        Ok(())
352    }
353}
354
355
356#[doc(hidden)]
357pub fn emit_as_str(builder: &HtmlEmitterBuilder, input: &str) -> String {
358    let doc: kdl::KdlDocument = input.parse().expect("Failed to parse as kdl doc");
359    let mut buf = Vec::<u8>::new();
360    let mut emitter = builder.build();
361    emitter.emit(&doc, &mut buf).expect("Failed to emit HTML");
362    String::from_utf8(buf).expect("Invalid utf8 found")
363}
364#[cfg(test)]
365pub mod tests {
366    use super::*;
367    use htmeta_auto_test::*;
368
369    auto_html_test!(basic_test);
370    auto_html_test!(basic_var);
371    auto_html_test!(var_scopes);
372
373    fn minified() -> HtmlEmitterBuilder {
374        let mut builder = HtmlEmitter::builder();
375        builder.minify();
376        builder
377    }
378
379    auto_html_test!(minified_basic, minified());
380    auto_html_test!(minified_var_scopes, minified());
381
382    struct ShouterPlugin;
383
384    impl IPlugin for ShouterPlugin {
385        fn dyn_clone(&self) -> Box<dyn IPlugin> {
386            Box::new(ShouterPlugin)
387        }
388        fn emit_node(&self, node: &KdlNode, context: PluginContext) -> EmitResult<bool> {
389            let name = node.name().value();
390            context.emitter.emit_tag(node, &name.to_uppercase(), context.indent, context.writer)?;
391            Ok(true)
392        }
393    }
394
395    fn with_plugin() -> HtmlEmitterBuilder {
396        let mut builder = HtmlEmitter::builder();
397        builder.add_plugin(ShouterPlugin);
398        builder
399    }
400
401    auto_html_test!(shouter_basic, with_plugin());
402}