diff options
author | Lars Wirzenius <liw@liw.fi> | 2023-04-09 08:08:40 +0300 |
---|---|---|
committer | Lars Wirzenius <liw@liw.fi> | 2023-04-09 10:24:54 +0300 |
commit | e8ca964f345d716cd776f3d54676d226b6e17b89 (patch) | |
tree | 41657f56b243e8db1dd57ba211ca5d53fbfbabf1 | |
parent | 473a405ab395a7efaf77348699b1aae827c87e94 (diff) | |
download | html-page-e8ca964f345d716cd776f3d54676d226b6e17b89.tar.gz |
feat: initial version
Sponsored-by: author
-rw-r--r-- | Cargo.lock | 32 | ||||
-rw-r--r-- | Cargo.toml | 2 | ||||
-rw-r--r-- | src/lib.rs | 401 | ||||
-rw-r--r-- | src/main.rs | 3 |
4 files changed, 435 insertions, 3 deletions
diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..66c20d9 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,32 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "html-escape" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" +dependencies = [ + "utf8-width", +] + +[[package]] +name = "html-rs" +version = "0.1.0" +dependencies = [ + "html-escape", + "line-col", +] + +[[package]] +name = "line-col" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e69cdf6b85b5c8dce514f694089a2cf8b1a702f6cd28607bcb3cf296c9778db" + +[[package]] +name = "utf8-width" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5190c9442dcdaf0ddd50f37420417d219ae5261bbf5db120d0f9bab996c9cba1" @@ -6,3 +6,5 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +html-escape = "0.2.13" +line-col = "0.2.1" diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..a7851fa --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,401 @@ +//! Construct and manipulate an HTML page represented using Rust types. + +//! Represent an HTML element. + +use html_escape::{encode_double_quoted_attribute, encode_safe}; +use std::collections::HashMap; +use std::fmt::{Display, Formatter}; + +/// The tag of an HTML5 element. +/// +/// Note that we only support HTML5 elements, as listed on +/// <https://html.spec.whatwg.org/multipage/#toc-semantics/>. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Tag { + A, + Abbr, + Address, + Area, + Article, + Aside, + Audio, + B, + Base, + Bdi, + Bdo, + Blockquote, + Body, + Br, + Button, + Canvas, + Caption, + Cite, + Code, + Col, + ColGroup, + Data, + DataList, + Dd, + Del, + Details, + Dfn, + Dialog, + Div, + Dl, + Dt, + Em, + Embed, + FieldSet, + FigCaption, + Figure, + Footer, + Form, + H1, + H2, + H3, + H4, + H5, + H6, + Head, + Header, + Hr, + Html, + I, + Iframe, + Img, + Input, + Ins, + Kbd, + Label, + Legend, + Li, + Link, + Main, + Map, + Mark, + Meta, + Meter, + Nav, + NoScript, + Object, + Ol, + OptGroup, + Option, + Output, + P, + Param, + Picture, + Pre, + Progress, + Q, + Rp, + Rt, + Ruby, + S, + Samp, + Script, + Section, + Select, + Small, + Source, + Span, + Strong, + Style, + Sub, + Summary, + Sup, + Svg, + Table, + Tbody, + Td, + Template, + TextArea, + Tfoot, + Th, + Time, + Title, + Tr, + Track, + U, + Ul, + Var, + Video, + Wbr, +} + +impl Display for Tag { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { + write!(f, "{:?}", self) + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct Attributes { + attrs: HashMap<String, AttributeValue>, +} + +impl Attributes { + fn set(&mut self, name: &str, value: &str) { + self.attrs + .insert(name.into(), AttributeValue::String(value.into())); + } + + fn set_boolean(&mut self, name: &str) { + self.attrs.insert(name.into(), AttributeValue::Boolean); + } + + fn unset(&mut self, name: &str) { + self.attrs.remove(name); + } + + fn get(&self, name: &str) -> Option<&AttributeValue> { + self.attrs.get(name) + } + + fn names(&self) -> impl Iterator<Item = &str> { + self.attrs.keys().map(|s| s.as_ref()) + } +} + +impl Display for Attributes { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { + for (name, value) in self.attrs.iter() { + match value { + AttributeValue::Boolean => write!(f, " {}", name)?, + AttributeValue::String(s) => { + write!(f, " {}={}", name, encode_double_quoted_attribute(s))? + } + } + } + Ok(()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AttributeValue { + String(String), + Boolean, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Element { + loc: Option<(usize, usize)>, + tag: Tag, + attrs: Attributes, + children: Vec<Content>, +} + +impl Element { + pub fn new(tag: Tag) -> Self { + Self { + tag, + attrs: Attributes::default(), + children: vec![], + loc: None, + } + } + + pub fn with_location(mut self, line: usize, col: usize) -> Self { + self.loc = Some((line, col)); + self + } + + pub fn tag(&self) -> Tag { + self.tag + } + + pub fn location(&self) -> Option<(usize, usize)> { + self.loc + } + + pub fn attributes(&self) -> impl Iterator<Item = &str> { + self.attrs.names() + } + + pub fn attribute(&self, name: &str) -> Option<&AttributeValue> { + self.attrs.get(name) + } + + pub fn set_attribute(&mut self, name: &str, value: &str) { + self.attrs.set(name, value); + } + + pub fn set_boolean_attribute(&mut self, name: &str) { + self.attrs.set_boolean(name); + } + + pub fn unset_attribute(&mut self, name: &str) { + self.attrs.unset(name); + } + + pub fn children(&self) -> &[Content] { + &self.children + } + + pub fn push_child(&mut self, child: Content) { + self.children.push(child); + } + + pub fn serialize(&self) -> String { + format!("{}", self) + } +} + +impl Display for Element { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { + if self.children.is_empty() { + write!(f, "<{}{}/>", self.tag, self.attrs)?; + } else { + write!(f, "<{}{}>", self.tag, self.attrs)?; + for child in &self.children { + write!(f, "{}", child)?; + } + write!(f, "</{}>", self.tag)?; + } + Ok(()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Content { + Text(String), + Element(Element), +} + +impl Content { + pub fn text(s: &str) -> Self { + Self::Text(s.into()) + } + + pub fn element(e: &Element) -> Self { + Self::Element(e.clone()) + } +} + +impl Display for Content { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { + match self { + Self::Text(s) => write!(f, "{}", encode_safe(s))?, + Self::Element(e) => write!(f, "{}", e)?, + } + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::{AttributeValue, Content, Element, Tag}; + + #[test] + fn element_has_correct_tag() { + let e = Element::new(Tag::P); + assert_eq!(e.tag(), Tag::P); + } + + #[test] + fn element_has_no_attributes_initially() { + let e = Element::new(Tag::P); + assert_eq!(e.attributes().count(), 0); + } + + #[test] + fn element_returns_no_value_for_missing_attribute() { + let e = Element::new(Tag::P); + assert_eq!(e.attribute("foo"), None); + } + + #[test] + fn can_add_attribute_to_element() { + let mut e = Element::new(Tag::P); + e.set_attribute("foo", "bar"); + assert_eq!( + e.attribute("foo"), + Some(&AttributeValue::String("bar".into())) + ); + } + + #[test] + fn can_add_boolean_attribute_to_element() { + let mut e = Element::new(Tag::P); + e.set_boolean_attribute("foo"); + assert_eq!(e.attribute("foo"), Some(&AttributeValue::Boolean)); + } + + #[test] + fn unset_attribute_is_unset() { + let e = Element::new(Tag::P); + assert_eq!(e.attribute("foo"), None); + } + + #[test] + fn can_unset_attribute_in_element() { + let mut e = Element::new(Tag::P); + e.set_attribute("foo", "bar"); + e.unset_attribute("foo"); + assert_eq!(e.attribute("foo"), None); + } + + #[test] + fn element_has_no_children_initially() { + let e = Element::new(Tag::P); + assert!(e.children().is_empty()); + } + + #[test] + fn add_child_to_element() { + let mut e = Element::new(Tag::P); + let child = Content::text("foo"); + e.push_child(child.clone()); + assert_eq!(e.children(), &[child]); + } + + #[test] + fn element_has_no_location_initially() { + let e = Element::new(Tag::P); + assert!(e.location().is_none()); + } + + #[test] + fn element_with_location() { + let e = Element::new(Tag::P).with_location(1, 2); + assert_eq!(e.location(), Some((1, 2))); + } + + #[test] + fn attribute_can_be_serialized() { + let mut e = Element::new(Tag::P); + e.set_attribute("foo", "bar"); + assert_eq!(e.serialize(), "<P foo=bar/>"); + } + + #[test] + fn dangerous_attribute_value_is_esacped() { + let mut e = Element::new(Tag::P); + e.set_attribute("foo", "<"); + assert_eq!(e.serialize(), "<P foo=</>"); + } + + #[test] + fn boolean_attribute_can_be_serialized() { + let mut e = Element::new(Tag::P); + e.set_boolean_attribute("foo"); + assert_eq!(e.serialize(), "<P foo/>"); + } + + #[test] + fn element_can_be_serialized() { + let mut e = Element::new(Tag::P); + e.push_child(Content::text("hello ")); + let mut world = Element::new(Tag::B); + world.push_child(Content::text("world")); + e.push_child(Content::Element(world)); + assert_eq!(e.serialize(), "<P>hello <B>world</B></P>"); + } + + #[test] + fn dangerous_text_is_escaped() { + let mut e = Element::new(Tag::P); + e.push_child(Content::text("hello <world>")); + assert_eq!(e.serialize(), "<P>hello <world></P>"); + } +} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index e7a11a9..0000000 --- a/src/main.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - println!("Hello, world!"); -} |