Compare commits

...

20 commits

Author SHA1 Message Date
01d9e06d63 Run cargo fmt 2026-04-20 22:29:53 +02:00
d26ef729eb Update edition, URL, and lints 2026-04-20 22:29:38 +02:00
f08eda2758 Satisfy clippy 2025-04-16 22:16:08 +02:00
190e00ed62 Fix typo 2025-04-16 22:12:21 +02:00
bb7dedc9eb Bump version to 0.2.0 2025-01-01 22:40:28 +01:00
59b81d637b Relax lower bound on http dependency
Originally, I just used the current version when adding the dependency,
but I see no reason to forbid earlier compatible versions.
2025-01-01 22:37:44 +01:00
ec7a461758 Update axum-core to 0.5.0 2025-01-01 22:09:32 +01:00
353934381b Bump version to 0.1.3 2024-12-21 20:04:46 +01:00
0167d3cea3 Fix html comment rendering
Apparently I just completely forgot to actually include <!-- and -->,
and instead just rendered the comment contents.
2024-12-20 16:51:11 +01:00
ec7bc571b1 Add Rel attr 2024-12-19 18:02:26 +01:00
7290a23c85 Simplify usage example 2024-12-17 01:44:41 +01:00
7b9fae31cd Bump version to 0.1.2 2024-12-14 18:09:50 +01:00
ceefa426f6 Add html::attr 2024-12-14 13:05:56 +01:00
abd0cc6247 Add Attr::set
The name "set" makes more sense compared to "append" than "new". "new"
only exists because originally there was no "append".
2024-12-14 12:37:46 +01:00
099f07ebac Fix grammar mistake 2024-12-09 00:24:46 +01:00
e172161b1e Bump version to 0.1.1 2024-12-08 00:24:39 +01:00
cb96987115 Tweak readme and lib docs 2024-12-08 00:20:51 +01:00
4a4225f72c Implement From<&String> for Content 2024-12-08 00:07:28 +01:00
6f0ae129fa Add Element::into_document
It's more convenient in some cases and also makes the example code less
"misleading": Now the string represents a full, correct HTML document.
2024-12-08 00:07:28 +01:00
b377ee2936 Include js helper function in README 2024-12-08 00:07:28 +01:00
10 changed files with 1323 additions and 62 deletions

View file

@ -6,7 +6,7 @@ A dependency update to an incompatible version is considered a breaking change.
## Releasing a new version
0. Ensure tests don't fail *ahem*
0. Ensure tests don't fail
1. Update dependencies in a separate commit, if necessary
2. Set new version number in [`Cargo.toml`](Cargo.toml)
3. Add new section in this changelog
@ -17,6 +17,46 @@ A dependency update to an incompatible version is considered a breaking change.
## Unreleased
## v0.2.0 - 2025-01-01
### Changed
- **(breaking)** Updated `axum-core` dependency to `0.5.0`
- Relaxed lower bound on `http` dependency to `1.0.0`
## v0.1.3 - 2024-12-21
### Added
- `html::attr::Rel`
### Fixed
- Rendering of HTML comments
## v0.1.2 - 2024-12-14
### Added
- `Attr::set`
- `html::attr`
### Deprecated
- `Attr::new` in favor of `Attr::set`
- `Attr::id` in favor of `html::attr::id`
- `Attr::class` in favor of `html::attr::class`
- `Attr::style` in favor of `html::attr::style`
- `Attr::data` in favor of `html::attr::data_x`
## v0.1.1 - 2024-12-08
### Added
- `Element::into_document`
- `impl From<&String> for Content`
- Eponymous JS helper function in readme
## v0.1.0 - 2024-12-02
Initial release

View file

@ -1,10 +1,10 @@
[package]
name = "el"
version = "0.1.0"
edition = "2021"
version = "0.2.0"
edition = "2024"
authors = ["Garmelon <garmelon@plugh.de>"]
description = "Write and manipulate HTML elements as data"
repository = "https://github.com/Garmelon/el"
repository = "https://git.plugh.de/Garmelon/el"
license = "MIT OR Apache-2.0"
keywords = ["html", "svg", "mathml", "hiccup"]
categories = ["web-programming", "template-engine"]
@ -13,16 +13,18 @@ categories = ["web-programming", "template-engine"]
axum = ["dep:axum-core", "dep:http"]
[dependencies]
axum-core = { version = "0.4.5", optional = true }
http = { version = "1.1.0", optional = true }
axum-core = { version = "0.5.0", optional = true }
http = { version = "1.0.0", optional = true }
[lints]
rust.unsafe_code = { level = "forbid", priority = 1 }
# Lint groups
rust.deprecated_safe = "warn"
rust.future_incompatible = "warn"
rust.keyword_idents = "warn"
rust.rust_2018_idioms = "warn"
rust.deprecated-safe = "warn"
rust.future-incompatible = "warn"
rust.keyword-idents = "warn"
rust.nonstandard-style = "warn"
rust.refining-impl-trait = "warn"
rust.rust-2018-idioms = "warn"
rust.unused = "warn"
# Individual lints
rust.let_underscore_drop = "warn"

View file

@ -1,34 +1,70 @@
# el
`el` is a Rust library for writing, modifying, and safely rendering HTML
elements as simple data structures. It is inspired by [hiccup] and named after a
small helper function I once wrote in JS.
`el` is a no-dependencies Rust library for writing, modifying, and safely
rendering HTML elements as simple data structures. It is inspired by [hiccup]
and named after a small helper function I once wrote in JS.
[hiccup]: https://github.com/weavejester/hiccup
## Usage example
## Show me a simple example
```rs
use el::{Attr, Render, html::*};
use el::{Render, html::*};
let page: String = html((
head((
meta(Attr::new("charset", "utf-8")),
meta((
Attr::new("name", "viewport"),
Attr::new("content", "width=device-width, initial-scale=1"),
attr::name("viewport"),
attr::content("width=device-width, initial-scale=1"),
)),
title("Example page"),
)),
body((
h1((Attr::id("heading"), "Example page")),
h1((attr::id("heading"), "Example page")),
p(("This is an example for a ", em("simple"), " web page.")),
)),
))
.into_document()
.render_to_string()
.unwrap();
```
## What now?
See the top-level crate documentation for more info.
## But what about that small helper function?
Here it is in full, for posterity:
```js
function el(name, attributes, ...children) {
const element = document.createElement(name);
for (const [name, value] of Object.entries(attributes))
element.setAttribute(name, value);
element.append(...children);
return element;
}
```
Use it like so:
```js
const page = el("html", {},
el("head", {},
el("meta", {
name: "viewport",
content: "width=device-width, initial-scale=1",
}),
el("title", {}, "Example page"),
),
el("body", {},
el("h1", { id: "heading" }, "Example page"),
el("p", {}, "This is an example for a ", el("em", {}, "simple"), " web page."),
),
);
```
## License
This entire project is dual-licensed under the [Apache 2.0] and [MIT] licenses.

View file

@ -1,5 +1,5 @@
use axum_core::response::IntoResponse;
use http::{header, HeaderValue, StatusCode};
use http::{HeaderValue, StatusCode, header};
use crate::{Document, Render};

View file

@ -58,10 +58,9 @@ pub fn is_valid_raw_text(tag_name: &str, text: &str) -> bool {
// "[...] followed by characters that case-insensitively match the tag
// name of the element [...]"
//
// Note: Since we know that tag names are ascii-only, we can convert
// both to lowercase for a case-insensitive comparison without weird
// unicode shenanigans.
if potential_tag_name.to_ascii_lowercase() != tag_name.to_ascii_lowercase() {
// Note: Since we know that tag names are ascii-only, we can use an
// ASCII-based case insensitive comparison without unicode shenanigans.
if !potential_tag_name.eq_ignore_ascii_case(tag_name) {
continue;
}

View file

@ -1,4 +1,4 @@
use std::collections::{btree_map::Entry, BTreeMap, HashMap};
use std::collections::{BTreeMap, HashMap, btree_map::Entry};
/// The kind of an element.
///
@ -87,6 +87,12 @@ impl Content {
impl From<String> for Content {
fn from(value: String) -> Self {
Self::Text(value)
}
}
impl From<&String> for Content {
fn from(value: &String) -> Self {
Self::text(value)
}
}
@ -239,6 +245,14 @@ impl Element {
self.add(c);
self
}
/// Convert this element into a [`Document`].
///
/// This function is equivalent to calling `self.into()` but may be more
/// convenient in some cases.
pub fn into_document(self) -> Document {
self.into()
}
}
/// A component can add itself to an [`Element`] by modifying it.
@ -287,7 +301,7 @@ impl Attr {
/// When this attribute is added to an [`Element`] through
/// [`ElementComponent::add_to_element`] and an attribute of the same name
/// already exists, it replaces that attribute's value.
pub fn new(name: impl ToString, value: impl ToString) -> Self {
pub fn set(name: impl ToString, value: impl ToString) -> Self {
Self {
name: name.to_string(),
value: value.to_string(),
@ -295,6 +309,16 @@ impl Attr {
}
}
/// Create or replace an attribute.
///
/// When this attribute is added to an [`Element`] through
/// [`ElementComponent::add_to_element`] and an attribute of the same name
/// already exists, it replaces that attribute's value.
#[deprecated = "use `Attr::set` instead"]
pub fn new(name: impl ToString, value: impl ToString) -> Self {
Self::set(name, value)
}
/// Create or append to an attribute.
///
/// When this attribute is added to an [`Element`] through
@ -316,28 +340,31 @@ impl Attr {
/// When rendering an empty attribute as HTML, the value can be omitted:
/// `name=""` is equivalent to just `name`.
pub fn yes(name: impl ToString) -> Self {
Self::new(name, "")
Self::set(name, "")
}
/// Create (or replace) an `id` attribute.
///
/// `Attr::id(id)` is equivalent to `Attr::new("id", id)`.
#[deprecated = "use `html::attr::id` instead"]
pub fn id(id: impl ToString) -> Self {
Self::new("id", id)
Self::set("id", id)
}
/// Create (or append) to a `class` attribute.
/// Create (or append to) a `class` attribute.
///
/// `Attr::class(class)` is equivalent to
/// `Attr::append("class", class, " ")`.
#[deprecated = "use `html::attr::class` instead"]
pub fn class(class: impl ToString) -> Self {
Self::append("class", class, " ")
}
/// Create (or append) to a `style` attribute.
/// Create (or append to) a `style` attribute.
///
/// `Attr::style(style)` is equivalent to
/// `Attr::append("style", style, ";")`.
#[deprecated = "use `html::attr::style` instead"]
pub fn style(style: impl ToString) -> Self {
Self::append("style", style, ";")
}
@ -348,8 +375,9 @@ impl Attr {
/// `Attr::new(format!("data-{name}"), value)`.
///
/// [mdn]: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*
#[deprecated = "use `html::attr::data_x` instead"]
pub fn data(name: impl ToString, value: impl ToString) -> Self {
Self::new(format!("data-{}", name.to_string()), value)
Self::set(format!("data-{}", name.to_string()), value)
}
}
@ -379,7 +407,7 @@ impl ElementComponent for Attr {
impl ElementComponent for HashMap<String, String> {
fn add_to_element(self, element: &mut Element) {
for (name, value) in self {
Attr::new(name, value).add_to_element(element);
Attr::set(name, value).add_to_element(element);
}
}
}
@ -387,7 +415,7 @@ impl ElementComponent for HashMap<String, String> {
impl ElementComponent for BTreeMap<String, String> {
fn add_to_element(self, element: &mut Element) {
for (name, value) in self {
Attr::new(name, value).add_to_element(element);
Attr::set(name, value).add_to_element(element);
}
}
}
@ -473,8 +501,12 @@ element_component_tuple!(C1, C2, C3, C4, C5, C6, C7, C8, C9, C10, C11);
element_component_tuple!(C1, C2, C3, C4, C5, C6, C7, C8, C9, C10, C11, C12);
element_component_tuple!(C1, C2, C3, C4, C5, C6, C7, C8, C9, C10, C11, C12, C13);
element_component_tuple!(C1, C2, C3, C4, C5, C6, C7, C8, C9, C10, C11, C12, C13, C14);
element_component_tuple!(C1, C2, C3, C4, C5, C6, C7, C8, C9, C10, C11, C12, C13, C14, C15);
element_component_tuple!(C1, C2, C3, C4, C5, C6, C7, C8, C9, C10, C11, C12, C13, C14, C15, C16);
element_component_tuple!(
C1, C2, C3, C4, C5, C6, C7, C8, C9, C10, C11, C12, C13, C14, C15
);
element_component_tuple!(
C1, C2, C3, C4, C5, C6, C7, C8, C9, C10, C11, C12, C13, C14, C15, C16
);
/// A full HTML document including doctype.
///

View file

@ -1,5 +1,9 @@
//! Definitions for all non-deprecated HTML elements
//! Definitions for HTML elements and attributes
//! ([MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element)).
//!
//! Deprecated HTML elements are not included.
pub mod attr;
use crate::{Element, ElementComponent, ElementKind};

1107
src/html/attr.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -33,22 +33,22 @@
//! ## Usage example
//!
//! ```
//! use el::{Attr, Render, html::*};
//! use el::{Render, html::*};
//!
//! let page: String = html((
//! head((
//! meta(Attr::new("charset", "utf-8")),
//! meta((
//! Attr::new("name", "viewport"),
//! Attr::new("content", "width=device-width, initial-scale=1"),
//! attr::name("viewport"),
//! attr::content("width=device-width, initial-scale=1"),
//! )),
//! title("Example page"),
//! )),
//! body((
//! h1((Attr::id("heading"), "Example page")),
//! h1((attr::id("heading"), "Example page")),
//! p(("This is an example for a ", em("simple"), " web page.")),
//! )),
//! ))
//! .into_document()
//! .render_to_string()
//! .unwrap();
//! ```
@ -66,6 +66,10 @@
//! ```
//!
//! [axum]: https://crates.io/crates/axum
//!
//! ## But what about that small helper function?
//!
//! See the readme for more details.
#[cfg(feature = "axum")]
mod axum;
@ -80,21 +84,20 @@ pub use self::{element::*, render::*};
#[cfg(test)]
mod tests {
use crate::{html::*, Attr, Content, Element, Render};
use crate::{Attr, Content, Element, Render, html::*};
#[test]
fn simple_website() {
let els = [
Content::doctype(),
html((
head(title("Hello")),
body((h1("Hello"), p(("Hello ", em("world"), "!")))),
))
.into(),
];
let page = html((
head(title("Hello")),
body((h1("Hello"), p(("Hello ", em("world"), "!")))),
))
.into_document()
.render_to_string()
.unwrap();
assert_eq!(
els.render_to_string().unwrap(),
page,
concat!(
"<!DOCTYPE html><html>",
"<head><title>Hello</title></head>",
@ -127,9 +130,11 @@ mod tests {
assert!(script("hello </script> world").render_to_string().is_err());
assert!(script("hello </ScRiPt ... world")
.render_to_string()
.is_err());
assert!(
script("hello </ScRiPt ... world")
.render_to_string()
.is_err()
);
}
#[test]
@ -146,10 +151,10 @@ mod tests {
fn attributes() {
assert_eq!(
input((
Attr::new("name", "tentacles"),
Attr::new("type", "number"),
Attr::new("min", 10),
Attr::new("max", 100),
Attr::set("name", "tentacles"),
attr::TypeInput::Number,
attr::min(10),
Attr::append("max", 100, "FOOBAA"),
))
.render_to_string()
.unwrap(),
@ -157,21 +162,55 @@ mod tests {
);
assert_eq!(
input((Attr::new("name", "horns"), Attr::yes("checked")))
input((Attr::set("name", "horns"), Attr::yes("checked")))
.render_to_string()
.unwrap(),
r#"<input checked name="horns">"#,
);
assert_eq!(
p((
attr::id("foo"),
attr::id("bar"),
attr::class("foo"),
attr::class("bar"),
))
.render_to_string()
.unwrap(),
r#"<p class="foo bar" id="bar"></p>"#,
)
}
#[test]
fn always_lowercase() {
assert_eq!(
Element::normal("HTML")
.with(Attr::new("LANG", "EN"))
.with(Attr::set("LANG", "EN"))
.render_to_string()
.unwrap(),
r#"<html lang="EN"></html>"#,
);
}
#[test]
fn comments() {
assert_eq!(
html(("<!--abc--> ", Content::comment("abc")))
.render_to_string()
.unwrap(),
r#"<html>&lt;!--abc--&gt; <!--abc--></html>"#,
);
assert_eq!(
html(Content::comment("Hello <!-- world -->!"))
.render_to_string()
.unwrap(),
r#"<html><!--Hello <!== world ==>!--></html>"#,
);
assert_eq!(
html(Content::comment("-><!-")).render_to_string().unwrap(),
r#"<html><!-- -><!- --></html>"#,
);
}
}

View file

@ -1,9 +1,8 @@
use std::{error, fmt};
use crate::{
check,
Document, check,
element::{Content, Element, ElementKind},
Document,
};
/// The cause of an [`Error`].
@ -246,6 +245,8 @@ fn render_text<W: fmt::Write>(w: &mut W, text: &str) -> Result<()> {
}
fn render_comment<W: fmt::Write>(w: &mut W, text: &str) -> Result<()> {
write!(w, "<!--")?;
// A comment...
// - must not start with the string ">"
// - must not start with the string "->"
@ -269,6 +270,7 @@ fn render_comment<W: fmt::Write>(w: &mut W, text: &str) -> Result<()> {
write!(w, " ")?;
}
write!(w, "-->")?;
Ok(())
}