summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2023-04-09 08:08:40 +0300
committerLars Wirzenius <liw@liw.fi>2023-04-09 10:24:54 +0300
commite8ca964f345d716cd776f3d54676d226b6e17b89 (patch)
tree41657f56b243e8db1dd57ba211ca5d53fbfbabf1
parent473a405ab395a7efaf77348699b1aae827c87e94 (diff)
downloadhtml-page-e8ca964f345d716cd776f3d54676d226b6e17b89.tar.gz
feat: initial version
Sponsored-by: author
-rw-r--r--Cargo.lock32
-rw-r--r--Cargo.toml2
-rw-r--r--src/lib.rs401
-rw-r--r--src/main.rs3
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"
diff --git a/Cargo.toml b/Cargo.toml
index 5ef327d..89907d2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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=&lt;/>");
+ }
+
+ #[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 &lt;world&gt;</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!");
-}