Let Rust detect changes in the Markdown file and generate HTML.
Tags: rust, markdown, html
Published: 2022-04-11 15:12:24

I have recently been learning Rust.

I recently needed to create some simple HTML files, mostly text.
Of course, creating HTML directly is a pain, so I figured I would write Markdown and have it converted to HTML.

I wondered if I could create that conversion tool in Rust anyway. That seemed like good learning programming to me.

This is a record of how I made a Markdown to HTML conversion tool in Rust.

## Convert Markdown to HTML using pulldown-cmark
There is a crate for converting Markdown to HTML called pulldown-cmark.

https://crates.io/crates/pulldown-cmark

I decided to use it to write the code for the conversion.

To use pulldown-cmark, I set up Cargo.toml like this.
By default, binaries were also built, but I wanted to use pulldown-cmark as a library this time, so this is how I described it.

```toml
pulldown-cmark = { version = "0.9.1", default-features = false }
```

I have written sample code for the conversion.
(It is almost exactly the same content as the pulldown-cmark documentation.)

```rust
use pulldown_cmark::{html, Options, Parser};

fn main() {
    let markdown_input = "# Hello world
 * １１１
 * ２２２
 * ３３３

~~Strikethroughs~~ *bold*.
";
    let mut options = Options::empty();
    // Strikeouts are not part of the CommonMark standard and must be explicitly enabled.
    options.insert(Options::ENABLE_STRIKETHROUGH);
    let parser = Parser::new_ext(markdown_input, options);

    let mut html_output = String::new();
    html::push_html(&mut html_output, parser);

    println!("{}", &html_output);
}
```

Here is the result of the conversion.

```html
<h1>Hello world</h1>
<ul>
<li>１１１</li>
<li>２２２</li>
<li>３３３</li>
</ul>
<p><del>Strikethroughs</del> <em>bold</em>.</p>
```

## Sample of reading markdown file and generating HTML file

Based on the code above, I wrote a sample that converts Markdown text read from a file called input.md and writes it to an output.html file.

(I think there is a hint of immaturity in the code, but this is something I will work on in the future.)

```rust
use pulldown_cmark::{html, Options, Parser};
use std::fs;
use std::fs::File;
use std::io::Write;
use std::path::Path;

fn read_md_file(file_path: &std::path::Path) -> Result<String, Box<dyn std::error::Error>> {
    let md = fs::read_to_string(file_path.to_str().unwrap())?;
    Ok(md)
}

fn write_html_file(
    file_path: &std::path::Path,
    html: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let mut file = File::create(file_path)?;
    write!(file, "{}", html)?;
    Ok(())
}

fn main() {
    let input_file_path = Path::new("./input.md");
    let markdown_input = read_md_file(input_file_path).unwrap();

    let mut options = Options::empty();
    options.insert(Options::ENABLE_STRIKETHROUGH);
    let parser = Parser::new_ext(&markdown_input, options);

    let mut html_output = String::new();
    html::push_html(&mut html_output, parser);

    let html_file_path = Path::new("./output.html");
    write_html_file(html_file_path, &html_output).unwrap();
}
```

## Detecting file changes in Rust

I have now accomplished the task of generating HTML files from markdown files.
But how can we detect changes in the markdown file?

I have decided to use another crate to detect file changes this time. That is a crate called notify.

https://github.com/notify-rs/notify

Incidentally, there seem to be two versions of notify being developed, 4.0 and 5.0.0-pre.14. I chose 4.0, which seems to be the stable version.
(I only looked at the documentation in the README, but it seems that each version is written in a very different way.)

I added the following to Cargo.toml and rewrote the code to detect changes in the markdown file and generate an HTML file.

```toml
notify = "4.0.16"
```

```rust
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use pulldown_cmark::{html, Options, Parser};
use std::fs;
use std::fs::File;
use std::io::Write;
use std::path::Path;
use std::sync::mpsc::channel;
use std::time::Duration;

fn read_md_file(file_path: &std::path::Path) -> Result<String, Box<dyn std::error::Error>> {
    let md = fs::read_to_string(file_path.to_str().unwrap())?;
    Ok(md)
}

fn write_html_file(
    file_path: &std::path::Path,
    html: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let mut file = File::create(file_path)?;
    write!(file, "{}", html)?;
    Ok(())
}

fn markdown_to_html(input_path: &std::path::Path, output_path: &std::path::Path) {
    let markdown_input = read_md_file(input_path).unwrap();

    let mut options = Options::empty();
    options.insert(Options::ENABLE_STRIKETHROUGH);
    let parser = Parser::new_ext(&markdown_input, options);

    let mut html_output = String::new();
    html::push_html(&mut html_output, parser);

    write_html_file(output_path, &html_output).unwrap();
}

fn main() -> notify::Result<()> {
    let input_file_path = Path::new("./input.md");
    let output_file_path = Path::new("./output.html");

    let (tx, rx) = channel();
    let mut watcher: RecommendedWatcher = Watcher::new(tx, Duration::from_secs(1))?;
    watcher.watch(input_file_path, RecursiveMode::Recursive)?;

    loop {
        match rx.recv() {
            Ok(_) => markdown_to_html(input_file_path, output_file_path),
            Err(err) => println!("watch error: {:?}", err),
        };
    }
}
```

## Detects changes to markdown files in a specified directory and writes the target files to HTML

We are finally close to our goal.
Now we wanted to further extend the above process to be able to detect changes within a specified directory.
However, it seemed that if I specified the directory in the notify, it would detect changes within the directory. Cool.

### About DebouncedEvent of notify

Incidentally, regarding event detection in notify, it seems that multiple types of events come across.

```rust
   loop {
        match rx.recv() {
            Ok(event) => println!("event: {:?}", event),
            Err(err) => println!("watch error: {:?}", err),
        };
    }
```

The one passed in this event is called DebouncedEvent, which is described in detail in this document.

https://docs.rs/notify/latest/notify/enum.DebouncedEvent.html

In cases such as this one, NoticeWrite is issued immediately after a write event for the target path, and Write is issued when a file is written and no event for the path is detected within the specified time.

We decided to use these events to perform the conversion in HTML this time, but decided to perform the HTML conversion process at the timing of Write instead of processing immediately after saving the file.

Therefore, I rewrote the code as follows.


```diff
fn main() -> notify::Result<()> {
-    let input_file_path = Path::new("./input.md");
-    let output_file_path = Path::new("./output.html");
-
     let (tx, rx) = channel();
     let mut watcher: RecommendedWatcher = Watcher::new(tx, Duration::from_secs(1))?;
-    watcher.watch(input_file_path, RecursiveMode::Recursive)?;
+
+    let input_dir_path = Path::new("./input");
+    watcher.watch(input_dir_path, RecursiveMode::Recursive)?;
 
     loop {
         match rx.recv() {
-            Ok(_) => markdown_to_html(input_file_path, output_file_path),
+            Ok(event) => match event {
+                notify::DebouncedEvent::Write(path) => {
+                    let input_file_path = Path::new(&path);
+                    let md_file_name = input_file_path.file_name().unwrap();
+                    match Path::new(md_file_name).extension() {
+                        Some(md_exntension) => {
+                            if md_exntension != "md" {
+                                eprintln!("ERROR: Only markdown files can be converted.");
+                                std::process::exit(1);
+                            }
+                        }
+                        None => {
+                            eprintln!("ERROR: Not found extension.");
+                            std::process::exit(1);
+                        }
+                    };
+
+                    let html_file_name = md_file_name.to_str().unwrap().replace(".md", ".html");
+                    let output_file_path = input_dir_path
+                        .parent()
+                        .unwrap()
+                        .join("output")
+                        .join(html_file_name);
+
+                    markdown_to_html(input_file_path, output_file_path.as_path());
+
+                    println!("=== Generated HTML ===");
+                    println!("Input file path: {:?}", input_file_path);
+                    println!(
+                        "Output file path: {:?}",
+                        output_file_path.canonicalize().unwrap()
+                    );
+                }
+                _ => (),
+            },
             Err(err) => println!("watch error: {:?}", err),
         };
     }
```

## I created a tool called gm2h.

This accomplished everything I wanted to do.

I then made this set of code available in the form of a CLI tool.
I have named it gm2h and published it on GitHub.


![gm2h logo](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/t38i3s5obwpql2iwqa9k.png)

https://github.com/shinshin86/gm2h

It is not yet released to crates.io, but can be installed and used directly from GitHub.

```sh
cargo install --git https://github.com/shinshin86/gm2h.git
```

Create a Markdown file in the current directory and then execute the following command
Then edit the Markdown file and an HTML file will be generated.

Incidentally, if I created a Markdown file after running gm2h, it did not seem to detect any changes to that file. I have not yet been able to find out if this is due to my code or if it is a specification of notify.

This is how it actually works.


![gm2h demo](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ibigsz1an1hbp5uraqo0.gif)

I actually use this in situations where I create HTML files, and it is quite useful.
There is still room for improvement, so I am planning to improve it little by little.
If you find any improvements, please send me a Pull Requst. I welcome it.

Thank you for reading to the end!