diff --git a/.gitignore b/.gitignore index a9d37c5..9adeb4e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ target Cargo.lock +docs/site diff --git a/Cargo.toml b/Cargo.toml index 13092b4..0b4badd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,4 +7,5 @@ members = [ ] exclude = [ "benchmarks", + "docs", ] diff --git a/docs/Cargo.toml b/docs/Cargo.toml new file mode 100644 index 0000000..4771728 --- /dev/null +++ b/docs/Cargo.toml @@ -0,0 +1,17 @@ +[package] + +name = "docs" +version = "0.0.0" +authors = ["Chris Wong <lambda.fairy@gmail.com>"] + +license = "CC-BY-SA-4.0" +repository = "https://github.com/lfairy/maud" +description = "Documentation for Maud." + +edition = "2018" + +[dependencies] +comrak = "*" +indexmap = "*" +maud = { path = "../maud" } +syntect = "*" diff --git a/docs/content/getting-started.md b/docs/content/getting-started.md new file mode 100644 index 0000000..86b42f7 --- /dev/null +++ b/docs/content/getting-started.md @@ -0,0 +1,56 @@ +# Getting started + +## Install nightly Rust + +Maud requires the nightly version of Rust. +If you're using `rustup`, +see the [documentation][rustup] +for how to install this version. + +[rustup]: https://github.com/rust-lang/rustup.rs/blob/master/README.md#working-with-nightly-rust + +## Add Maud to your project + +Once Rust is set up, +create a new project with Cargo: + +```sh +cargo new --bin pony-greeter +cd pony-greeter +``` + +Add `maud` to your `Cargo.toml`: + +```toml +[dependencies] +maud = "*" +``` + +Then save the following to `src/main.rs`: + +```rust +#![feature(proc_macro_hygiene)] + +extern crate maud; +use maud::html; + +fn main() { + let name = "Lyra"; + let markup = html! { + p { "Hi, " (name) "!" } + }; + println!("{}", markup.into_string()); +} +``` + +`html!` takes a single argument: a template using Maud's custom syntax. This call expands to an expression of type [`Markup`][Markup], which can then be converted to a `String` using `.into_string()`. + +[Markup]: https://docs.rs/maud/*/maud/type.Markup.html + +Run this program with `cargo run`, and you should get the following: + +``` +<p>Hi, Lyra!</p> +``` + +Congrats – you've written your first Maud program! diff --git a/docs/content/index.md b/docs/content/index.md new file mode 100644 index 0000000..9f26d61 --- /dev/null +++ b/docs/content/index.md @@ -0,0 +1,33 @@ +```rust +html! { + h1 { "Hello, world!" } + p.intro { + "This is an example of the " + a href="https://github.com/lfairy/maud" { "Maud" } + " template language." + } +} +``` + +Maud is an HTML [template engine] for Rust. It's implemented as a macro, `html!`, which compiles your markup to specialized Rust code. This unique approach makes Maud templates blazing fast, super type-safe, and easy to deploy. + +[template engine]: https://www.simple-is-better.org/template/ + +## Tight integration with Rust + +Since Maud is a Rust macro, it can borrow most of its features from the host language. Pattern matching and `for` loops work as they do in Rust. There is no need to derive JSON conversions, as your templates can work with Rust values directly. + +## Type safety + +Your templates are checked by the compiler, just like the code around them. Any typos will be caught at compile time, not after your app has already started. + +## Minimal runtime + +Since most of the work happens at compile time, the runtime footprint is small. The Maud runtime library, including integration with the [Rocket] and [Actix] web frameworks, is around 100 SLoC. + +[Rocket]: https://rocket.rs/ +[Actix]: https://actix.rs/ + +## Simple deployment + +There is no need to track separate template files, since all relevant code is linked into the final executable. diff --git a/docs/src/main.rs b/docs/src/main.rs new file mode 100644 index 0000000..53cd9bc --- /dev/null +++ b/docs/src/main.rs @@ -0,0 +1,123 @@ +#![feature(crate_visibility_modifier)] +#![feature(proc_macro_hygiene)] + +use comrak::{self, Arena, ComrakOptions}; +use comrak::nodes::{AstNode, NodeCodeBlock, NodeHeading, NodeHtmlBlock, NodeValue}; +use indexmap::IndexMap; +use std::error::Error; +use std::fs; +use std::io; +use std::mem; +use std::path::Path; +use std::string::FromUtf8Error; +use syntect::parsing::SyntaxSet; +use syntect::highlighting::{Color, ThemeSet}; +use syntect::html::highlighted_html_for_string; + +mod views; + +const BOOK_FILES: &[&str] = &[ + "index", + "getting-started", +]; + +fn main() -> Result<(), Box<dyn Error>> { + fs::create_dir_all("site")?; + + let arena = Arena::new(); + let options = ComrakOptions { + ext_header_ids: Some("".to_string()), + ..ComrakOptions::default() + }; + + let mut pages = IndexMap::<&str, _>::new(); + + for path in BOOK_FILES { + let mut input_path = Path::new("content").join(path); + input_path.set_extension("md"); + + let page = load_page(&arena, &options, &input_path)?; + + pages.insert(path, page); + } + + for path in pages.keys() { + let mut output_path = Path::new("site").join(path); + output_path.set_extension("html"); + println!("{}", output_path.display()); + let markup = views::main(&options, path, &pages); + fs::write(output_path, markup.into_string())?; + } + + fs::copy("styles.css", "site/styles.css")?; + + Ok(()) +} + +struct Page<'a> { + title: Option<&'a AstNode<'a>>, + content: &'a AstNode<'a>, +} + +fn load_page<'a>( + arena: &'a Arena<AstNode<'a>>, + options: &ComrakOptions, + path: &Path, +) -> io::Result<Page<'a>> { + let buffer = fs::read_to_string(path)?; + let content = comrak::parse_document(arena, &buffer, options); + + let title = content + .first_child() + .filter(|node| { + let mut data = node.data.borrow_mut(); + if let NodeValue::Heading(NodeHeading { level: 1, .. }) = data.value { + node.detach(); + data.value = NodeValue::Document; + true + } else { + false + } + }); + + lower_headings(content); + highlight_code(content) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + + Ok(Page { title, content }) +} + +fn lower_headings<'a>(root: &'a AstNode<'a>) { + for node in root.descendants() { + let mut data = node.data.borrow_mut(); + if let NodeValue::Heading(NodeHeading { level, .. }) = &mut data.value { + *level += 1; + } + } +} + +fn highlight_code<'a>(root: &'a AstNode<'a>) -> Result<(), FromUtf8Error> { + let ss = SyntaxSet::load_defaults_newlines(); + let ts = ThemeSet::load_defaults(); + let mut theme = ts.themes["InspiredGitHub"].clone(); + theme.settings.background = Some(Color { r: 0xee, g: 0xee, b: 0xee, a: 0xff }); + for node in root.descendants() { + let mut data = node.data.borrow_mut(); + if let NodeValue::CodeBlock(NodeCodeBlock { info, literal, .. }) = &mut data.value { + let info = String::from_utf8(mem::replace(info, Vec::new()))?; + let syntax = ss.find_syntax_by_token(&info) + .unwrap_or_else(|| ss.find_syntax_plain_text()); + let mut literal = String::from_utf8(mem::replace(literal, Vec::new()))?; + if !literal.ends_with('\n') { + // Syntect expects a trailing newline + literal.push('\n'); + } + let html = highlighted_html_for_string(&literal, &ss, syntax, &theme); + data.value = NodeValue::HtmlBlock(NodeHtmlBlock { + block_type: 0, + literal: html.into_bytes(), + }); + } + } + Ok(()) +} diff --git a/docs/src/views.rs b/docs/src/views.rs new file mode 100644 index 0000000..949299b --- /dev/null +++ b/docs/src/views.rs @@ -0,0 +1,104 @@ +use comrak::{self, ComrakOptions}; +use comrak::nodes::AstNode; +use crate::Page; +use indexmap::IndexMap; +use maud::{DOCTYPE, Markup, Render, html}; +use std::io; +use std::str; + +struct StringWriter<'a>(&'a mut String); + +impl<'a> io::Write for StringWriter<'a> { + fn write(&mut self, buf: &[u8]) -> io::Result<usize> { + str::from_utf8(buf) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) + .map(|s| { + self.0.push_str(s); + buf.len() + }) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +struct Comrak<'a>(&'a AstNode<'a>, &'a ComrakOptions); + +impl<'a> Render for Comrak<'a> { + fn render_to(&self, buffer: &mut String) { + comrak::format_html(self.0, self.1, &mut StringWriter(buffer)).unwrap(); + } +} + +struct ComrakText<'a>(&'a AstNode<'a>, &'a ComrakOptions); + +impl<'a> Render for ComrakText<'a> { + fn render_to(&self, buffer: &mut String) { + comrak::format_commonmark(self.0, self.1, &mut StringWriter(buffer)).unwrap(); + } +} + +crate fn main<'a>( + options: &'a ComrakOptions, + path: &str, + pages: &IndexMap<&str, Page<'a>>, +) -> Markup { + let page = &pages[path]; + html! { + (DOCTYPE) + meta charset="utf-8"; + title { + @if let Some(title) = page.title { + (ComrakText(title, options)) + " \u{2013} " + } + "Maud, a macro for writing HTML" + } + link rel="stylesheet" href="styles.css"; + + header { + h1 { + a href="." { + "maud" + } + } + p { "a macro for writing html" } + } + + nav { + ul { + @for (other_path, other_page) in pages { + @if let Some(title) = other_page.title { + li { + a href={ (other_path) ".html" } { + (Comrak(title, options)) + } + } + } + } + } + ul { + li { + a href="https://docs.rs/maud/" { + "API documentation" + } + } + li { + a href="https://github.com/lfairy/maud" { + "GitHub" + } + } + } + } + + main { + @if let Some(title) = page.title { + h2 { + (Comrak(title, options)) + } + } + (Comrak(page.content, options)) + } + } +} diff --git a/docs/styles.css b/docs/styles.css new file mode 100644 index 0000000..4a2c76f --- /dev/null +++ b/docs/styles.css @@ -0,0 +1,75 @@ +html { + box-sizing: border-box; + font: 16px/1.5 "Helvetica Neue", Helvetica, Arial, sans-serif; +} + +*, *::before, *::after { + box-sizing: inherit; +} + +body { + margin: 0 auto; + padding: 2rem; + max-width: 40rem; +} + +header { + display: flex; + flex-wrap: wrap; + align-items: baseline; +} + +header h1 { + margin: 0; +} + +header p { + margin: 0 0 0 1rem; +} + +nav ul { + padding: 0; + list-style: none; +} + +pre { + padding: 1rem; + font: 15px Consolas, monospace; + background: #eee; +} + +a:link { + text-decoration: none; +} + +a:focus, a:hover, a:active { + background: #fdd; +} + +@media (min-width: 60rem) { + body { + max-width: 56rem; /* 15 + 1 + 40 */ + display: grid; + grid-template-columns: 15rem auto; + grid-gap: 1rem; + } + + header { + grid-column: 1 / 3; + grid-row: 1; + } + + nav { + grid-column: 1; + grid-row: 2; + } + + main { + grid-column: 2; + grid-row: 2; + } + + main h2:first-child { + margin-top: .5rem; + } +} diff --git a/docs/watch.sh b/docs/watch.sh new file mode 100755 index 0000000..0bf2ab6 --- /dev/null +++ b/docs/watch.sh @@ -0,0 +1,12 @@ +#!/bin/sh + +set -e + +python3 -m http.server -d site & +server_pid=$! +trap 'kill $server_pid' EXIT + +while true +do + find . -name '*.rs' -o -name '*.md' | entr -d cargo run +done