瀏覽代碼

wip: studio upgrades

Jonathan Kelley 3 年之前
父節點
當前提交
5fdb14446b
共有 14 個文件被更改,包括 501 次插入72 次删除
  1. 2 2
      .vscode/settings.json
  2. 7 2
      Cargo.toml
  3. 20 8
      src/builder.rs
  4. 40 5
      src/cli.rs
  5. 3 2
      src/config.rs
  6. 55 23
      src/develop.rs
  7. 19 0
      src/err.html
  8. 3 0
      src/error.rs
  9. 21 8
      src/lib.rs
  10. 0 2
      src/logging.rs
  11. 4 20
      src/main.rs
  12. 194 0
      src/studio.rs
  13. 101 0
      src/translate.rs
  14. 32 0
      tests/test.html

+ 2 - 2
.vscode/settings.json

@@ -1,3 +1,3 @@
 {
-    "rust-analyzer.inlayHints.enable": false
-}
+  "rust-analyzer.inlayHints.enable": true
+}

+ 7 - 2
Cargo.toml

@@ -18,14 +18,19 @@ argh = "0.1.4"
 serde = "1.0.120"
 serde_json = "1.0.61"
 async-std = { version = "1.9.0", features = ["attributes"] }
-tide = "0.15.0"
+tide = "0.16.0"
 fs_extra = "1.2.0"
 
-cargo_toml = "0.8.1"
+cargo_toml = "0.10.0"
 futures = "0.3.12"
 notify = "5.0.0-pre.4"
 rjdebounce = "0.2.1"
 tempfile = "3.2.0"
+html_parser = "0.6.2"
+
+tui = { version = "0.15.0", features = ["crossterm"] }
+crossterm = "0.19.0"
+tui-template = { git = "https://github.com/jkelleyrtp/tui-builder.git" }
 
 [[bin]]
 

+ 20 - 8
src/builder.rs

@@ -1,10 +1,13 @@
 use crate::{
     cli::BuildOptions,
-    config::{Config, ExecutableType},
-    error::Result,
+    config::{CrateConfig, ExecutableType},
+    error::{Error, Result},
 };
 use log::{info, warn};
-use std::{io::Write, process::Command};
+use std::{
+    io::{Read, Write},
+    process::Command,
+};
 use wasm_bindgen_cli_support::Bindgen;
 
 pub struct BuildConfig {}
@@ -19,7 +22,7 @@ impl Default for BuildConfig {
     }
 }
 
-pub fn build(config: &Config, _build_config: &BuildConfig) -> Result<()> {
+pub fn build(config: &CrateConfig, _build_config: &BuildConfig) -> Result<()> {
     /*
     [1] Build the project with cargo, generating a wasm32-unknown-unknown target (is there a more specific, better target to leverage?)
     [2] Generate the appropriate build folders
@@ -28,7 +31,7 @@ pub fn build(config: &Config, _build_config: &BuildConfig) -> Result<()> {
     [5] Link up the html page to the wasm module
     */
 
-    let Config {
+    let CrateConfig {
         out_dir,
         crate_dir,
         target_dir,
@@ -60,9 +63,16 @@ pub fn build(config: &Config, _build_config: &BuildConfig) -> Result<()> {
     };
 
     let mut child = cmd.spawn()?;
-    let _err_code = child.wait()?;
-
-    info!("Build complete!");
+    let output = child.wait()?;
+
+    if output.success() {
+        info!("Build complete!");
+    } else {
+        log::error!("Build failed!");
+        let mut reason = String::new();
+        child.stderr.unwrap().read_to_string(&mut reason)?;
+        return Err(Error::BuildFailed(reason));
+    }
 
     // [2] Establish the output directory structure
     let bindgen_outdir = out_dir.join("wasm");
@@ -129,6 +139,8 @@ fn gen_page(module: &str) -> String {
     <meta charset="UTF-8" />
   </head>
   <body>
+    <div id="dioxusroot">
+    </div>
     <!-- Note the usage of `type=module` here as this is an ES6 module -->
     <script type="module">
       import init from "{}";

+ 40 - 5
src/cli.rs

@@ -8,22 +8,24 @@ pub struct LaunchOptions {
 }
 
 /// The various kinds of commands that `wasm-pack` can execute.
-#[derive(FromArgs, PartialEq, Debug)]
+#[derive(FromArgs, PartialEq, Debug, Clone)]
 #[argh(subcommand)]
 pub enum LaunchCommand {
     Develop(DevelopOptions),
     Build(BuildOptions),
+    Translate(TranslateOptions),
     Test(TestOptions),
     Publish(PublishOptions),
+    Studio(StudioOptions),
 }
 
 /// Publish your yew application to Github Pages, Netlify, or S3
-#[derive(FromArgs, PartialEq, Debug)]
+#[derive(FromArgs, PartialEq, Debug, Clone)]
 #[argh(subcommand, name = "publish")]
 pub struct PublishOptions {}
 
 /// 🔬 test your yew application!
-#[derive(FromArgs, PartialEq, Debug)]
+#[derive(FromArgs, PartialEq, Debug, Clone)]
 #[argh(subcommand, name = "test")]
 pub struct TestOptions {
     /// an example in the crate
@@ -35,7 +37,7 @@ pub struct TestOptions {
 #[derive(FromArgs, PartialEq, Debug, Clone)]
 #[argh(subcommand, name = "build")]
 pub struct BuildOptions {
-    /// an optional direction which is "up" by default
+    /// the directory output
     #[argh(option, short = 'o', default = "String::from(\"public\")")]
     pub outdir: String,
 
@@ -46,10 +48,18 @@ pub struct BuildOptions {
     /// develop in release mode
     #[argh(switch, short = 'r')]
     pub release: bool,
+
+    /// hydrate the `dioxusroot` element with this content
+    #[argh(option, short = 'h')]
+    pub hydrate: Option<String>,
+
+    /// custom template
+    #[argh(option, short = 't')]
+    pub template: Option<String>,
 }
 
 /// 🛠 Start a development server
-#[derive(FromArgs, PartialEq, Debug)]
+#[derive(FromArgs, PartialEq, Debug, Clone)]
 #[argh(subcommand, name = "develop")]
 pub struct DevelopOptions {
     /// an example in the crate
@@ -59,4 +69,29 @@ pub struct DevelopOptions {
     /// develop in release mode
     #[argh(switch, short = 'r')]
     pub release: bool,
+
+    /// hydrate the `dioxusroot` element with this content
+    #[argh(option, short = 'h')]
+    pub hydrate: Option<String>,
+
+    /// custom template
+    #[argh(option, short = 't')]
+    pub template: Option<String>,
 }
+
+/// 🛠 Translate some 3rd party template into rsx
+#[derive(FromArgs, PartialEq, Debug, Clone)]
+#[argh(subcommand, name = "translate")]
+pub struct TranslateOptions {
+    /// an example in the crate
+    #[argh(option, short = 'f')]
+    pub file: Option<String>,
+
+    /// an example in the crate
+    #[argh(option, short = 't')]
+    pub text: Option<String>,
+}
+/// 🛠 Translate some 3rd party template into rsx
+#[derive(FromArgs, PartialEq, Debug, Clone)]
+#[argh(subcommand, name = "studio")]
+pub struct StudioOptions {}

+ 3 - 2
src/config.rs

@@ -1,11 +1,12 @@
 use crate::{
     cli::{BuildOptions, DevelopOptions},
     error::Result,
+    LaunchCommand,
 };
 use std::{io::Write, path::PathBuf, process::Command};
 
 #[derive(Debug, Clone)]
-pub struct Config {
+pub struct CrateConfig {
     pub out_dir: PathBuf,
     pub crate_dir: PathBuf,
     pub workspace_dir: PathBuf,
@@ -23,7 +24,7 @@ pub enum ExecutableType {
     Example(String),
 }
 
-impl Config {
+impl CrateConfig {
     pub fn new() -> Result<Self> {
         let crate_dir = crate::cargo::crate_root()?;
         let workspace_dir = crate::cargo::workspace_root()?;

+ 55 - 23
src/develop.rs

@@ -1,4 +1,4 @@
-use crate::{builder::BuildConfig, cli::DevelopOptions, config::Config, error::Result};
+use crate::{builder::BuildConfig, cli::DevelopOptions, config::CrateConfig, error::Result};
 use async_std::prelude::FutureExt;
 
 use async_std::future;
@@ -7,6 +7,8 @@ use async_std::prelude::*;
 use log::info;
 use notify::{RecommendedWatcher, RecursiveMode, Watcher};
 use std::path::PathBuf;
+use std::sync::atomic::AtomicBool;
+use std::sync::Arc;
 use std::time::Duration;
 
 pub struct DevelopConfig {}
@@ -16,15 +18,20 @@ impl Into<DevelopConfig> for DevelopOptions {
     }
 }
 
-pub async fn start(config: &Config, _options: &DevelopConfig) -> Result<()> {
+type ErrStatus = Arc<AtomicBool>;
+
+pub async fn start(config: &CrateConfig, _options: &DevelopConfig) -> Result<()> {
     log::info!("Starting development server 🚀");
 
-    let Config { out_dir, .. } = config;
+    let CrateConfig { out_dir, .. } = config;
+
+    let is_err = Arc::new(AtomicBool::new(false));
 
     // Spawn the server onto a seperate task
     // This lets the task progress while we handle file updates
-    let server = async_std::task::spawn(launch_server(out_dir.clone()));
-    let watcher = async_std::task::spawn(watch_directory(config.clone()));
+    let server = async_std::task::spawn(launch_server(out_dir.clone(), is_err.clone()));
+
+    let watcher = async_std::task::spawn(watch_directory(config.clone(), is_err.clone()));
 
     match server.race(watcher).await {
         Err(e) => log::warn!("Error running development server, {:?}", e),
@@ -34,14 +41,19 @@ pub async fn start(config: &Config, _options: &DevelopConfig) -> Result<()> {
     Ok(())
 }
 
-async fn watch_directory(config: Config) -> Result<()> {
+async fn watch_directory(config: CrateConfig, is_err: ErrStatus) -> Result<()> {
     // Create a channel to receive the events.
     let (watcher_tx, watcher_rx) = async_std::channel::bounded(100);
 
     // Automatically select the best implementation for your platform.
     // You can also access each implementation directly e.g. INotifyWatcher.
     let mut watcher: RecommendedWatcher = Watcher::new(move |res| {
+<<<<<<< HEAD
         async_std::task::block_on(watcher_tx.send(res));
+=======
+        // send an event
+        let _ = async_std::task::block_on(watcher_tx.send(res));
+>>>>>>> 9451713 (wip: studio upgrades)
     })
     .expect("failed to make watcher");
 
@@ -53,44 +65,63 @@ async fn watch_directory(config: Config) -> Result<()> {
         .watch(&src_dir.join("src"), RecursiveMode::Recursive)
         .expect("Failed to watch dir");
 
-    watcher
-        .watch(&src_dir.join("examples"), RecursiveMode::Recursive)
-        .expect("Failed to watch dir");
+    match watcher.watch(&src_dir.join("examples"), RecursiveMode::Recursive) {
+        Ok(_) => {}
+        Err(e) => log::warn!("Failed to watch examples dir, {:?}", e),
+    }
 
     let build_config = BuildConfig::default();
 
     'run: loop {
-        crate::builder::build(&config, &build_config)?;
-
-        // Wait for the message with a debounce
-        let _msg = watcher_rx
-            .recv()
-            .join(future::ready(1_usize).delay(Duration::from_millis(2000)))
-            .await;
+        match crate::builder::build(&config, &build_config) {
+            Ok(_) => {
+                is_err.store(false, std::sync::atomic::Ordering::Relaxed);
+                async_std::task::sleep(std::time::Duration::from_millis(500)).await;
+            }
+            Err(err) => is_err.store(true, std::sync::atomic::Ordering::Relaxed),
+        };
+
+        let mut msg = None;
+        loop {
+            let new_msg = watcher_rx.recv().await.unwrap().unwrap();
+            if !watcher_rx.is_empty() {
+                msg = Some(new_msg);
+                break;
+            }
+        }
 
         info!("File updated, rebuilding app");
     }
     Ok(())
 }
 
-async fn launch_server(outdir: PathBuf) -> Result<()> {
+async fn launch_server(outdir: PathBuf, is_err: ErrStatus) -> Result<()> {
     let _crate_dir = crate::cargo::crate_root()?;
     let _workspace_dir = crate::cargo::workspace_root()?;
 
-    let mut app = tide::with_state(ServerState::new(outdir.to_owned()));
+    let mut app = tide::with_state(ServerState::new(outdir.to_owned(), is_err));
     let p = outdir.display().to_string();
 
     app.at("/")
         .get(|req: tide::Request<ServerState>| async move {
             log::info!("Connected to development server");
             let state = req.state();
-            Ok(tide::Body::from_file(state.serv_path.clone().join("index.html")).await?)
+
+            match state.is_err.load(std::sync::atomic::Ordering::Relaxed) {
+                true => Ok(tide::Body::from_string(format!(
+                    include_str!("./err.html"),
+                    err = "_"
+                ))),
+                false => {
+                    Ok(tide::Body::from_file(state.serv_path.clone().join("index.html")).await?)
+                }
+            }
         })
         .serve_dir(p)?;
 
     let port = "8080";
-    let serve_addr = format!("0.0.0.0:{}", port);
-    // let serve_addr = format!("127.0.0.1:{}", port);
+    // let serve_addr = format!("0.0.0.0:{}", port);
+    let serve_addr = format!("127.0.0.1:{}", port);
 
     info!("App available at http://{}", serve_addr);
     app.listen(serve_addr).await?;
@@ -103,9 +134,10 @@ async fn launch_server(outdir: PathBuf) -> Result<()> {
 #[derive(Clone)]
 struct ServerState {
     serv_path: PathBuf,
+    is_err: ErrStatus,
 }
 impl ServerState {
-    fn new(serv_path: PathBuf) -> Self {
-        Self { serv_path }
+    fn new(serv_path: PathBuf, is_err: ErrStatus) -> Self {
+        Self { serv_path, is_err }
     }
 }

+ 19 - 0
src/err.html

@@ -0,0 +1,19 @@
+<html>
+
+<head></head>
+
+<body>
+    <div>
+        <h1>
+            Sorry, but building your application failed.
+        </h1>
+        <p>
+            Here's the error:
+        </p>
+        <code>
+            {err}
+        </code>
+    </div>
+</body>
+
+</html>

+ 3 - 0
src/error.rs

@@ -14,6 +14,9 @@ pub enum Error {
     #[error("Failed to write error")]
     FailedToWrite,
 
+    #[error("Building project failed")]
+    BuildFailed(String),
+
     #[error("Failed to write error")]
     CargoError(String),
 

+ 21 - 8
src/lib.rs

@@ -1,8 +1,21 @@
-pub mod builder;
-pub mod cargo;
-pub mod cli;
-pub mod config;
-pub mod develop;
-pub mod error;
-pub mod logging;
-pub mod watch;
+mod builder;
+mod cargo;
+mod cli;
+mod config;
+mod develop;
+mod error;
+mod logging;
+mod studio;
+mod translate;
+mod watch;
+
+pub use builder::*;
+pub use cargo::*;
+pub use cli::*;
+pub use config::*;
+pub use develop::*;
+pub use error::*;
+pub use logging::*;
+pub use studio::*;
+pub use translate::*;
+pub use watch::*;

+ 0 - 2
src/logging.rs

@@ -44,6 +44,4 @@ pub fn set_up_logging() {
         .chain(std::io::stdout())
         .apply()
         .unwrap();
-
-    debug!("finished setting up logging! yay!");
 }

+ 4 - 20
src/main.rs

@@ -1,28 +1,12 @@
-use dioxus_studio as diopack;
-use dioxus_studio::cli::{LaunchCommand, LaunchOptions};
+use dioxus_studio::{set_up_logging, LaunchCommand, LaunchOptions};
 
 #[async_std::main]
-async fn main() -> diopack::error::Result<()> {
-    diopack::logging::set_up_logging();
+async fn main() -> dioxus_studio::Result<()> {
+    set_up_logging();
 
     let opts: LaunchOptions = argh::from_env();
-    let mut config = diopack::config::Config::new()?;
 
-    match opts.command {
-        LaunchCommand::Build(options) => {
-            config.with_build_options(&options);
-            diopack::builder::build(&config, &(options.into()))?;
-        }
-
-        LaunchCommand::Develop(options) => {
-            config.with_develop_options(&options);
-            diopack::develop::start(&config, &(options.into())).await?;
-        }
-
-        _ => {
-            todo!("Command not currently implemented");
-        }
-    }
+    dioxus_studio::Studio::new(opts).start().await?;
 
     Ok(())
 }

+ 194 - 0
src/studio.rs

@@ -0,0 +1,194 @@
+//! It's better to store all the configuration in one spot
+//!
+use tui_template::tuiapp::TuiApp;
+
+use crate::*;
+use std::{any::Any, io::Write, path::PathBuf, process::Command};
+
+pub struct Studio {
+    command: LaunchOptions,
+    headless: bool,
+    example: Option<String>,
+    outdir: Option<String>,
+    release: bool,
+    hydrate: Option<String>,
+    template: Option<String>,
+    translate_file: Option<String>,
+    crate_config: Option<CrateConfig>,
+}
+
+impl Studio {
+    pub fn new(command: LaunchOptions) -> Self {
+        let headless = true;
+        let release = false;
+        let example = None;
+        let outdir = None;
+        let hydrate = None;
+        let template = None;
+        let translate_file = None;
+        let crate_config = None;
+
+        match command.command {
+            LaunchCommand::Translate(_) => todo!(),
+            LaunchCommand::Develop(_) => todo!(),
+            LaunchCommand::Build(_) => todo!(),
+            LaunchCommand::Test(_) => todo!(),
+            LaunchCommand::Publish(_) => todo!(),
+            LaunchCommand::Studio(StudioOptions { .. }) => {
+                //
+            }
+        };
+
+        Self {
+            command,
+            headless,
+            example,
+            outdir,
+            release,
+            hydrate,
+            template,
+            translate_file,
+            crate_config,
+        }
+    }
+
+    pub async fn start(self) -> Result<()> {
+        match self.command.command {
+            LaunchCommand::Develop(_) => todo!(),
+            LaunchCommand::Build(_) => todo!(),
+            LaunchCommand::Translate(_) => todo!(),
+            LaunchCommand::Test(_) => todo!(),
+            LaunchCommand::Publish(_) => todo!(),
+            LaunchCommand::Studio(_) => self.launch_studio().await?,
+        }
+        Ok(())
+    }
+
+    pub async fn launch_studio(mut self) -> Result<()> {
+        let task = async_std::task::spawn_blocking(|| async move {
+            let mut app = TuiStudio {
+                cfg: self,
+                hooks: vec![],
+                hook_idx: 0,
+            };
+            app.launch(250).expect("tui app crashed :(");
+        });
+        let r = task.await.await;
+
+        Ok(())
+    }
+}
+
+use tui::{
+    backend::Backend,
+    layout::{Constraint, Direction, Layout, Rect},
+    style::{Color, Modifier, Style},
+    symbols,
+    text::{Span, Spans},
+    widgets::canvas::{Canvas, Line, Map, MapResolution, Rectangle},
+    widgets::{
+        Axis, BarChart, Block, BorderType, Borders, Cell, Chart, Dataset, Gauge, LineGauge, List,
+        ListItem, Paragraph, Row, Sparkline, Table, Tabs, Wrap,
+    },
+    Frame,
+};
+
+struct TuiStudio {
+    cfg: Studio,
+
+    hook_idx: usize,
+    hooks: Vec<Box<dyn Any>>,
+}
+impl TuiStudio {
+    fn use_hook<F: 'static>(&mut self, f: impl FnOnce() -> F) -> &mut F {
+        if self.hook_idx == self.hooks.len() {
+            self.hooks.push(Box::new(f()));
+        }
+        let idx = self.hook_idx;
+        self.hook_idx += 1;
+        let hook = self.hooks.get_mut(idx).unwrap();
+        let r = hook.downcast_mut::<F>().unwrap();
+        r
+    }
+}
+
+impl TuiApp for TuiStudio {
+    fn event_handler(&self, action: crossterm::event::Event) -> anyhow::Result<()> {
+        match action {
+            crossterm::event::Event::Key(_) => {}
+            crossterm::event::Event::Mouse(_) => {}
+            crossterm::event::Event::Resize(_, _) => {}
+        }
+        Ok(())
+    }
+
+    fn handle_key(&mut self, key: crossterm::event::KeyEvent) {}
+
+    fn tick(&mut self) {}
+
+    fn should_quit(&self) -> bool {
+        false
+    }
+
+    fn render<B: tui::backend::Backend>(&mut self, f: &mut tui::Frame<B>) {
+        self.hook_idx = 0;
+
+        // Wrapping block for a group
+        // Just draw the block and the group on the same area and build the group
+        // with at least a margin of 1
+        let size = f.size();
+        let block = Block::default()
+            .borders(Borders::ALL)
+            .title("Main block with round corners")
+            .border_type(BorderType::Rounded);
+        f.render_widget(block, size);
+        let chunks = Layout::default()
+            .direction(Direction::Vertical)
+            .margin(4)
+            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
+            .split(f.size());
+
+        let top_chunks = Layout::default()
+            .direction(Direction::Horizontal)
+            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
+            .split(chunks[0]);
+        let block = Block::default()
+            .title(vec![
+                Span::styled("With", Style::default().fg(Color::Yellow)),
+                Span::from(" background"),
+            ])
+            .style(Style::default().bg(Color::Green));
+        f.render_widget(block, top_chunks[0]);
+
+        let block = Block::default().title(Span::styled(
+            "Styled title",
+            Style::default()
+                .fg(Color::White)
+                .bg(Color::Red)
+                .add_modifier(Modifier::BOLD),
+        ));
+        f.render_widget(block, top_chunks[1]);
+
+        let bottom_chunks = Layout::default()
+            .direction(Direction::Horizontal)
+            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
+            .split(chunks[1]);
+        let block = Block::default().title("With borders").borders(Borders::ALL);
+        f.render_widget(block, bottom_chunks[0]);
+        let block = Block::default()
+            .title("With styled borders and doubled borders")
+            .border_style(Style::default().fg(Color::Cyan))
+            .borders(Borders::LEFT | Borders::RIGHT)
+            .border_type(BorderType::Double);
+        f.render_widget(block, bottom_chunks[1]);
+    }
+}
+
+impl TuiStudio {
+    fn render_list<B: tui::backend::Backend>(&mut self, f: &mut tui::Frame<B>) {
+        let options = [
+            "Bundle", "Develop",
+            //
+        ];
+    }
+}

+ 101 - 0
src/translate.rs

@@ -0,0 +1,101 @@
+use std::fmt::{Debug, Display, Formatter};
+
+use anyhow::Result;
+
+use html_parser::{Dom, Node};
+
+pub fn translate_from_html_file(target: &str) -> Result<RsxRenderer> {
+    use std::fs::File;
+    use std::io::Read;
+    let mut file = File::open(target).unwrap();
+
+    let mut contents = String::new();
+    file.read_to_string(&mut contents)
+        .expect("Failed to read your file.");
+    translate_from_html_to_rsx(&contents, true)
+}
+
+pub fn translate_from_html_to_rsx(html: &str, as_call: bool) -> Result<RsxRenderer> {
+    let contents = Dom::parse(html)?;
+    let renderer = RsxRenderer {
+        as_call,
+        dom: contents,
+    };
+    Ok(renderer)
+}
+
+pub struct RsxRenderer {
+    dom: Dom,
+    as_call: bool,
+}
+
+impl Display for RsxRenderer {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        if self.as_call {
+            writeln!(f, r##"use dioxus::prelude::*;"##)?;
+            writeln!(f, r##"const Component: FC<()> = |cx| cx.render(rsx!{{"##)?;
+        }
+        for child in &self.dom.children {
+            render_child(f, child, 1)?;
+        }
+        if self.as_call {
+            write!(f, r##"}});"##)?;
+        }
+        Ok(())
+    }
+}
+
+fn render_child(f: &mut Formatter<'_>, child: &Node, il: u32) -> std::fmt::Result {
+    write_tabs(f, il);
+    match child {
+        Node::Text(t) => writeln!(f, "\"{}\"", t)?,
+        Node::Comment(e) => writeln!(f, "/* {} */", e)?,
+        Node::Element(el) => {
+            // open the tag
+            write!(f, "{} {{ ", &el.name)?;
+
+            // todo: dioxus will eventually support classnames
+            // for now, just write them with a space between each
+            let class_iter = &mut el.classes.iter();
+            if let Some(first_class) = class_iter.next() {
+                write!(f, "class: \"{}", first_class)?;
+                for next_class in class_iter {
+                    write!(f, " {}", next_class)?;
+                }
+                write!(f, "\",")?;
+            }
+            write!(f, "\n")?;
+
+            // write the attributes
+            if let Some(id) = &el.id {
+                write_tabs(f, il + 1)?;
+                writeln!(f, "id: \"{}\",", id)?;
+            }
+
+            for (name, value) in &el.attributes {
+                write_tabs(f, il + 1)?;
+                match value {
+                    Some(val) => writeln!(f, "{}: \"{}\",", name, val)?,
+                    None => writeln!(f, "{}: \"\",", name)?,
+                }
+            }
+
+            // now the children
+            for child in &el.children {
+                render_child(f, child, il + 1)?;
+            }
+
+            // close the tag
+            write_tabs(f, il)?;
+            writeln!(f, "}}")?;
+        }
+    };
+    Ok(())
+}
+
+fn write_tabs(f: &mut Formatter, num: u32) -> std::fmt::Result {
+    for _ in 0..num {
+        write!(f, "    ")?
+    }
+    Ok(())
+}

+ 32 - 0
tests/test.html

@@ -0,0 +1,32 @@
+<section class="text-gray-600 body-font">
+    <div class="container px-5 py-24 mx-auto">
+        <div class="flex flex-wrap -mx-4 -mb-10 text-center">
+            <div class="sm:w-1/2 mb-10 px-4">
+                <div class="rounded-lg h-64 overflow-hidden">
+                    <img alt="content" class="object-cover object-center h-full w-full"
+                        src="https://dummyimage.com/1201x501">
+                </div>
+                <h2 class="title-font text-2xl font-medium text-gray-900 mt-6 mb-3">Buy YouTube Videos</h2>
+                <p class="leading-relaxed text-base">
+                    Williamsburg occupy sustainable snackwave gochujang. Pinterest cornhole brunch, slow-carb neutra
+                    irony.
+                </p>
+                <button
+                    class="flex mx-auto mt-6 text-white bg-indigo-500 border-0 py-2 px-5 focus:outline-none hover:bg-indigo-600 rounded">Button</button>
+            </div>
+            <div class="sm:w-1/2 mb-10 px-4">
+                <div class="rounded-lg h-64 overflow-hidden">
+                    <img alt="content" class="object-cover object-center h-full w-full"
+                        src="https://dummyimage.com/1202x502">
+                </div>
+                <h2 class="title-font text-2xl font-medium text-gray-900 mt-6 mb-3">The Catalyzer</h2>
+                <p class="leading-relaxed text-base">
+                    Williamsburg occupy sustainable snackwave gochujang. Pinterest
+                    cornhole brunch, slow-carb neutra irony.
+                </p>
+                <button
+                    class="flex mx-auto mt-6 text-white bg-indigo-500 border-0 py-2 px-5 focus:outline-none hover:bg-indigo-600 rounded">Button</button>
+            </div>
+        </div>
+    </div>
+</section>