Model and render elements
This commit is contained in:
parent
c8d9cf16f5
commit
cc3f85e6e1
5 changed files with 694 additions and 0 deletions
78
src/check.rs
Normal file
78
src/check.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
/// <https://infra.spec.whatwg.org/#ascii-alpha>
|
||||||
|
pub fn is_ascii_alpha(c: char) -> bool {
|
||||||
|
c.is_ascii_alphabetic()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <https://infra.spec.whatwg.org/#ascii-alphanumeric>
|
||||||
|
pub fn is_ascii_alphanumeric(c: char) -> bool {
|
||||||
|
c.is_ascii_alphanumeric()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <https://html.spec.whatwg.org/multipage/syntax.html#syntax-tag-name>
|
||||||
|
///
|
||||||
|
/// The rules around what is a valid tag name are complicated. The standard
|
||||||
|
/// doesn't give an easy answer. Because of this, we're conservative in what we
|
||||||
|
/// allow. This way, the output we produce should parse correctly in a wide
|
||||||
|
/// range of circumstances while following the standard.
|
||||||
|
pub fn is_valid_tag_name(name: &str) -> bool {
|
||||||
|
!name.is_empty()
|
||||||
|
&& name.chars().take(1).all(is_ascii_alpha)
|
||||||
|
&& name.chars().all(is_ascii_alphanumeric)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <https://html.spec.whatwg.org/multipage/syntax.html#syntax-attribute-name>
|
||||||
|
///
|
||||||
|
/// The rules around what is a valid attribute name are complicated. The
|
||||||
|
/// standard doesn't give an easy answer. Because of this, we're conservative in
|
||||||
|
/// what we allow. This way, the output we produce should parse correctly in a
|
||||||
|
/// wide range of circumstances while following the standard.
|
||||||
|
pub fn is_valid_attribute_name(name: &str) -> bool {
|
||||||
|
!name.is_empty()
|
||||||
|
&& name.chars().take(1).all(is_ascii_alpha)
|
||||||
|
&& name
|
||||||
|
.chars()
|
||||||
|
.all(|c| is_ascii_alphanumeric(c) || c == '-' || c == '_')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// https://html.spec.whatwg.org/multipage/syntax.html#cdata-rcdata-restrictions
|
||||||
|
///
|
||||||
|
/// The tag name must be ascii-only.
|
||||||
|
pub fn is_valid_raw_text(tag_name: &str, text: &str) -> bool {
|
||||||
|
// In case we ever decide to relax tag name ascii requirements.
|
||||||
|
assert!(tag_name.is_ascii());
|
||||||
|
|
||||||
|
// "The text in raw text and escapable raw text elements must not contain
|
||||||
|
// any occurrences of the string "</" (U+003C LESS-THAN SIGN, U+002F
|
||||||
|
// SOLIDUS) [...]"
|
||||||
|
for (i, _) in text.match_indices("</") {
|
||||||
|
let start = i + "</".len();
|
||||||
|
|
||||||
|
let potential_tag_name = text[start..]
|
||||||
|
.chars()
|
||||||
|
.take(tag_name.chars().count())
|
||||||
|
.collect::<String>();
|
||||||
|
|
||||||
|
// "[...] 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() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "[...] followed by [...]"
|
||||||
|
let Some(trailing) = text[start + potential_tag_name.len()..].chars().next() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// "[...] one of U+0009 CHARACTER TABULATION (tab), U+000A LINE FEED
|
||||||
|
// (LF), U+000C FORM FEED (FF), U+000D CARRIAGE RETURN (CR), U+0020
|
||||||
|
// SPACE, U+003E GREATER-THAN SIGN (>), or U+002F SOLIDUS (/)."
|
||||||
|
if matches!(trailing, '\t' | '\n' | '\x0C' | '\r' | ' ' | '>' | '/') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
92
src/element.rs
Normal file
92
src/element.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
/// <https://html.spec.whatwg.org/multipage/syntax.html#elements-2>
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ElementKind {
|
||||||
|
Void,
|
||||||
|
Template,
|
||||||
|
RawText,
|
||||||
|
EscapableRawText,
|
||||||
|
Foreign,
|
||||||
|
Normal,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Content {
|
||||||
|
Raw(String),
|
||||||
|
Text(String),
|
||||||
|
Comment(String),
|
||||||
|
Element(Element),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Content {
|
||||||
|
pub fn raw(str: impl ToString) -> Self {
|
||||||
|
Self::Raw(str.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn text(str: impl ToString) -> Self {
|
||||||
|
Self::Text(str.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn comment(str: impl ToString) -> Self {
|
||||||
|
Self::Comment(str.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn doctype() -> Self {
|
||||||
|
Self::raw("<!DOCTYPE html>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for Content {
|
||||||
|
fn from(value: String) -> Self {
|
||||||
|
Self::text(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for Content {
|
||||||
|
fn from(value: &str) -> Self {
|
||||||
|
Self::text(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Element> for Content {
|
||||||
|
fn from(value: Element) -> Self {
|
||||||
|
Self::Element(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Element {
|
||||||
|
pub name: String,
|
||||||
|
pub kind: ElementKind,
|
||||||
|
pub attributes: BTreeMap<String, String>,
|
||||||
|
pub children: Vec<Content>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element {
|
||||||
|
pub fn new(name: impl ToString, kind: ElementKind) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.to_string().to_ascii_lowercase(),
|
||||||
|
kind,
|
||||||
|
attributes: BTreeMap::new(),
|
||||||
|
children: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normal(name: impl ToString) -> Self {
|
||||||
|
Self::new(name, ElementKind::Normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn attr(mut self, name: impl ToString, value: impl ToString) -> Self {
|
||||||
|
self.attributes
|
||||||
|
.insert(name.to_string().to_ascii_lowercase(), value.to_string());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn attr_true(self, name: impl ToString) -> Self {
|
||||||
|
self.attr(name, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn child(mut self, child: impl Into<Content>) -> Self {
|
||||||
|
self.children.push(child.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
166
src/elements.rs
Normal file
166
src/elements.rs
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
//! Definitions for all non-deprecated HTML elements.
|
||||||
|
//!
|
||||||
|
//! <https://developer.mozilla.org/en-US/docs/Web/HTML/Element>
|
||||||
|
|
||||||
|
use crate::{Element, ElementKind};
|
||||||
|
|
||||||
|
macro_rules! element {
|
||||||
|
( $name:ident ) => {
|
||||||
|
element!($name, ElementKind::Normal);
|
||||||
|
};
|
||||||
|
( $name:ident, $kind:expr ) => {
|
||||||
|
pub fn $name() -> Element {
|
||||||
|
Element::new(stringify!($name), $kind)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main root
|
||||||
|
element!(html);
|
||||||
|
|
||||||
|
// Document metadata
|
||||||
|
element!(base, ElementKind::Void);
|
||||||
|
element!(head);
|
||||||
|
element!(link, ElementKind::Void);
|
||||||
|
element!(meta, ElementKind::Void);
|
||||||
|
element!(style, ElementKind::RawText);
|
||||||
|
element!(title, ElementKind::EscapableRawText);
|
||||||
|
|
||||||
|
// Sectioning root
|
||||||
|
element!(body);
|
||||||
|
|
||||||
|
// Content sectioning
|
||||||
|
element!(address);
|
||||||
|
element!(article);
|
||||||
|
element!(aside);
|
||||||
|
element!(footer);
|
||||||
|
element!(header);
|
||||||
|
element!(h1);
|
||||||
|
element!(h2);
|
||||||
|
element!(h3);
|
||||||
|
element!(h4);
|
||||||
|
element!(h5);
|
||||||
|
element!(h6);
|
||||||
|
element!(hgroup);
|
||||||
|
element!(main);
|
||||||
|
element!(nav);
|
||||||
|
element!(section);
|
||||||
|
element!(search);
|
||||||
|
|
||||||
|
// Text content
|
||||||
|
element!(blockquote);
|
||||||
|
element!(dd);
|
||||||
|
element!(div);
|
||||||
|
element!(dl);
|
||||||
|
element!(dt);
|
||||||
|
element!(figcaption);
|
||||||
|
element!(figure);
|
||||||
|
element!(hr, ElementKind::Void);
|
||||||
|
element!(li);
|
||||||
|
element!(menu);
|
||||||
|
element!(ol);
|
||||||
|
element!(p);
|
||||||
|
element!(pre);
|
||||||
|
element!(ul);
|
||||||
|
|
||||||
|
// Inline text semantics
|
||||||
|
element!(a);
|
||||||
|
element!(abbr);
|
||||||
|
element!(b);
|
||||||
|
element!(bdi);
|
||||||
|
element!(bdo);
|
||||||
|
element!(br, ElementKind::Void);
|
||||||
|
element!(cite);
|
||||||
|
element!(code);
|
||||||
|
element!(data);
|
||||||
|
element!(dfn);
|
||||||
|
element!(em);
|
||||||
|
element!(i);
|
||||||
|
element!(kbd);
|
||||||
|
element!(mark);
|
||||||
|
element!(q);
|
||||||
|
element!(rp);
|
||||||
|
element!(rt);
|
||||||
|
element!(ruby);
|
||||||
|
element!(s);
|
||||||
|
element!(samp);
|
||||||
|
element!(small);
|
||||||
|
element!(span);
|
||||||
|
element!(strong);
|
||||||
|
element!(sub);
|
||||||
|
element!(sup);
|
||||||
|
element!(time);
|
||||||
|
element!(u);
|
||||||
|
element!(var);
|
||||||
|
element!(wbr, ElementKind::Void);
|
||||||
|
|
||||||
|
// Image and multimedia
|
||||||
|
element!(area, ElementKind::Void);
|
||||||
|
element!(audio);
|
||||||
|
element!(img, ElementKind::Void);
|
||||||
|
element!(map);
|
||||||
|
element!(track, ElementKind::Void);
|
||||||
|
element!(video);
|
||||||
|
|
||||||
|
// Embedded content
|
||||||
|
element!(embed, ElementKind::Void);
|
||||||
|
element!(fencedframe);
|
||||||
|
element!(iframe);
|
||||||
|
element!(object);
|
||||||
|
element!(picture);
|
||||||
|
element!(portal);
|
||||||
|
element!(source, ElementKind::Void);
|
||||||
|
|
||||||
|
// SVG and MathML
|
||||||
|
// TODO Proper SVG and MathML support
|
||||||
|
element!(svg, ElementKind::Foreign);
|
||||||
|
element!(math, ElementKind::Foreign);
|
||||||
|
|
||||||
|
// Scripting
|
||||||
|
element!(canvas);
|
||||||
|
element!(noscript);
|
||||||
|
element!(script, ElementKind::RawText);
|
||||||
|
|
||||||
|
// Demarcating edits
|
||||||
|
element!(del);
|
||||||
|
element!(ins);
|
||||||
|
|
||||||
|
// Table content
|
||||||
|
element!(caption);
|
||||||
|
element!(col, ElementKind::Void);
|
||||||
|
element!(colgroup);
|
||||||
|
element!(table);
|
||||||
|
element!(tbody);
|
||||||
|
element!(td);
|
||||||
|
element!(tfoot);
|
||||||
|
element!(th);
|
||||||
|
element!(thead);
|
||||||
|
element!(tr);
|
||||||
|
|
||||||
|
// Forms
|
||||||
|
element!(button);
|
||||||
|
element!(datalist);
|
||||||
|
element!(fieldset);
|
||||||
|
element!(form);
|
||||||
|
element!(input, ElementKind::Void);
|
||||||
|
element!(label);
|
||||||
|
element!(legend);
|
||||||
|
element!(meter);
|
||||||
|
element!(optgroup);
|
||||||
|
element!(option);
|
||||||
|
element!(output);
|
||||||
|
element!(progress);
|
||||||
|
element!(select);
|
||||||
|
element!(textarea, ElementKind::EscapableRawText);
|
||||||
|
|
||||||
|
// Interactive elements
|
||||||
|
element!(details);
|
||||||
|
element!(dialog);
|
||||||
|
element!(summary);
|
||||||
|
|
||||||
|
// Web Components
|
||||||
|
element!(slot);
|
||||||
|
element!(template, ElementKind::Template);
|
||||||
|
|
||||||
|
// Obsolete and deprecated elements
|
||||||
|
// Intentionally excluded!
|
||||||
123
src/lib.rs
123
src/lib.rs
|
|
@ -1 +1,124 @@
|
||||||
|
//! Create HTML by manipulating elements as structured data. Inspired by the
|
||||||
|
//! clojure library [hiccup][hiccup].
|
||||||
|
//!
|
||||||
|
//! [hiccup]: https://github.com/weavejester/hiccup
|
||||||
|
|
||||||
|
mod check;
|
||||||
|
mod element;
|
||||||
|
pub mod elements;
|
||||||
|
mod render;
|
||||||
|
|
||||||
|
pub use self::{element::*, elements::*, render::*};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::{elements::*, render::Render, Content, Element};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn simple_website() {
|
||||||
|
let els = [
|
||||||
|
Content::doctype(),
|
||||||
|
html()
|
||||||
|
.child(head().child(title().child("Hello")))
|
||||||
|
.child(
|
||||||
|
body()
|
||||||
|
.child(h1().child("Hello"))
|
||||||
|
.child(p().child("Hello ").child(em().child("world")).child("!")),
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
els.render_to_string().unwrap(),
|
||||||
|
concat!(
|
||||||
|
"<!DOCTYPE html><html>",
|
||||||
|
"<head><title>Hello</title></head>",
|
||||||
|
"<body><h1>Hello</h1><p>Hello <em>world</em>!</p></body>",
|
||||||
|
"</html>",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn void_elements() {
|
||||||
|
// Difference between void and non-void
|
||||||
|
assert_eq!(head().render_to_string().unwrap(), "<head></head>");
|
||||||
|
assert_eq!(input().render_to_string().unwrap(), "<input>");
|
||||||
|
|
||||||
|
// Void elements must not contain any children
|
||||||
|
assert!(input().child(p()).render_to_string().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_text_elements() {
|
||||||
|
assert_eq!(
|
||||||
|
script()
|
||||||
|
.child("foo <script> & </style> bar")
|
||||||
|
.render_to_string()
|
||||||
|
.unwrap(),
|
||||||
|
"<script>foo <script> & </style> bar</script>",
|
||||||
|
);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{:?}",
|
||||||
|
script().child("hello </script> world").render_to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(script()
|
||||||
|
.child("hello </script> world")
|
||||||
|
.render_to_string()
|
||||||
|
.is_err());
|
||||||
|
|
||||||
|
assert!(script()
|
||||||
|
.child("hello </ScRiPt ... world")
|
||||||
|
.render_to_string()
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escaped_text_elements() {
|
||||||
|
assert_eq!(
|
||||||
|
textarea()
|
||||||
|
.child("foo <p> & bar")
|
||||||
|
.render_to_string()
|
||||||
|
.unwrap(),
|
||||||
|
"<textarea>foo <p> & bar</textarea>",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(textarea().child(p()).render_to_string().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attributes() {
|
||||||
|
assert_eq!(
|
||||||
|
input()
|
||||||
|
.attr("name", "tentacles")
|
||||||
|
.attr("type", "number")
|
||||||
|
.attr("min", 10)
|
||||||
|
.attr("max", 100)
|
||||||
|
.render_to_string()
|
||||||
|
.unwrap(),
|
||||||
|
r#"<input max="100" min="10" name="tentacles" type="number">"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
input()
|
||||||
|
.attr("name", "horns")
|
||||||
|
.attr_true("checked")
|
||||||
|
.render_to_string()
|
||||||
|
.unwrap(),
|
||||||
|
r#"<input checked name="horns">"#,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn always_lowercase() {
|
||||||
|
assert_eq!(
|
||||||
|
Element::normal("HTML")
|
||||||
|
.attr("LANG", "EN")
|
||||||
|
.render_to_string()
|
||||||
|
.unwrap(),
|
||||||
|
r#"<html lang="EN"></html>"#,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
235
src/render.rs
Normal file
235
src/render.rs
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
use std::{error, fmt};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
check,
|
||||||
|
element::{Content, Element, ElementKind},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ErrorCause {
|
||||||
|
Format(fmt::Error),
|
||||||
|
InvalidTagName(String),
|
||||||
|
InvalidAttrName(String),
|
||||||
|
InvalidChild,
|
||||||
|
InvalidRawText(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Error {
|
||||||
|
reverse_path: Vec<String>,
|
||||||
|
cause: ErrorCause,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
pub fn new(cause: ErrorCause) -> Self {
|
||||||
|
Self {
|
||||||
|
reverse_path: vec![],
|
||||||
|
cause,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn at(mut self, index: usize, child: &Content) -> Self {
|
||||||
|
self.reverse_path.push(match child {
|
||||||
|
Content::Element(el) => format!("{index}:{}", el.name),
|
||||||
|
_ => index.to_string(),
|
||||||
|
});
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
let path = self
|
||||||
|
.reverse_path
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.map(|s| s as &str)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(".");
|
||||||
|
|
||||||
|
write!(f, "Render error at {path}: ")?;
|
||||||
|
|
||||||
|
match &self.cause {
|
||||||
|
ErrorCause::Format(error) => write!(f, "{error}")?,
|
||||||
|
ErrorCause::InvalidTagName(name) => write!(f, "Invalid tag name {name:?}")?,
|
||||||
|
ErrorCause::InvalidAttrName(name) => write!(f, "Invalid attribute name {name:?}")?,
|
||||||
|
ErrorCause::InvalidChild => write!(f, "Invalid child")?,
|
||||||
|
ErrorCause::InvalidRawText(text) => write!(f, "Invalid raw text {text:?}")?,
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl error::Error for Error {}
|
||||||
|
|
||||||
|
impl From<fmt::Error> for Error {
|
||||||
|
fn from(value: fmt::Error) -> Self {
|
||||||
|
Self::new(ErrorCause::Format(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
pub trait Render {
|
||||||
|
fn render<W: fmt::Write>(&self, w: &mut W) -> Result<()>;
|
||||||
|
|
||||||
|
fn render_to_string(&self) -> Result<String> {
|
||||||
|
let mut result = String::new();
|
||||||
|
self.render(&mut result)?;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for [Content] {
|
||||||
|
fn render<W: fmt::Write>(&self, w: &mut W) -> Result<()> {
|
||||||
|
for content in self {
|
||||||
|
content.render(w)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Content {
|
||||||
|
fn render<W: fmt::Write>(&self, w: &mut W) -> Result<()> {
|
||||||
|
match self {
|
||||||
|
Self::Raw(text) => write!(w, "{text}")?,
|
||||||
|
Self::Text(text) => render_text(w, text)?,
|
||||||
|
Self::Comment(text) => render_comment(w, text)?,
|
||||||
|
Self::Element(element) => element.render(w)?,
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Element {
|
||||||
|
fn render<W: fmt::Write>(&self, w: &mut W) -> Result<()> {
|
||||||
|
// Checks
|
||||||
|
if !check::is_valid_tag_name(&self.name) {
|
||||||
|
return Err(Error::new(ErrorCause::InvalidTagName(self.name.clone())));
|
||||||
|
}
|
||||||
|
for name in self.attributes.keys() {
|
||||||
|
if !check::is_valid_attribute_name(name) {
|
||||||
|
return Err(Error::new(ErrorCause::InvalidAttrName(name.clone())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening tag
|
||||||
|
write!(w, "<{}", self.name)?;
|
||||||
|
for (name, value) in &self.attributes {
|
||||||
|
write!(w, " {name}")?;
|
||||||
|
if !value.is_empty() {
|
||||||
|
write!(w, "=")?;
|
||||||
|
render_attribute_value(w, value)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.children.is_empty() {
|
||||||
|
// Closing early
|
||||||
|
match self.kind {
|
||||||
|
ElementKind::Void => write!(w, ">")?,
|
||||||
|
ElementKind::Foreign => write!(w, " />")?,
|
||||||
|
_ => write!(w, "></{}>", self.name)?,
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
write!(w, ">")?;
|
||||||
|
|
||||||
|
// Children
|
||||||
|
for (i, child) in self.children.iter().enumerate() {
|
||||||
|
match self.kind {
|
||||||
|
ElementKind::Void => Err(Error::new(ErrorCause::InvalidChild)),
|
||||||
|
ElementKind::RawText => match child {
|
||||||
|
c @ Content::Raw(_) => c.render(w),
|
||||||
|
Content::Text(text) if check::is_valid_raw_text(&self.name, text) => {
|
||||||
|
write!(w, "{text}").map_err(|e| e.into())
|
||||||
|
}
|
||||||
|
Content::Text(text) => {
|
||||||
|
Err(Error::new(ErrorCause::InvalidRawText(text.clone())))
|
||||||
|
}
|
||||||
|
_ => Err(Error::new(ErrorCause::InvalidChild)),
|
||||||
|
},
|
||||||
|
ElementKind::EscapableRawText => match child {
|
||||||
|
c @ (Content::Raw(_) | Content::Text(_)) => c.render(w),
|
||||||
|
_ => Err(Error::new(ErrorCause::InvalidChild)),
|
||||||
|
},
|
||||||
|
_ => child.render(w),
|
||||||
|
}
|
||||||
|
.map_err(|e| e.at(i, child))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closing tag
|
||||||
|
if self.kind != ElementKind::Void {
|
||||||
|
write!(w, "</{}>", self.name)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_text<W: fmt::Write>(w: &mut W, text: &str) -> Result<()> {
|
||||||
|
// As far as I can tell, it should be sufficient to escape `&` and `<`.
|
||||||
|
// `>` is escaped too for symmetry, not for any real reason.
|
||||||
|
//
|
||||||
|
// Reasoning: Whenever we're inside tags, we're in one of these states,
|
||||||
|
// https://html.spec.whatwg.org/multipage/parsing.html#data-state
|
||||||
|
// https://html.spec.whatwg.org/multipage/parsing.html#rawtext-state
|
||||||
|
// https://html.spec.whatwg.org/multipage/parsing.html#rcdata-state
|
||||||
|
|
||||||
|
for c in text.chars() {
|
||||||
|
match c {
|
||||||
|
'&' => write!(w, "&")?,
|
||||||
|
'<' => write!(w, "<")?,
|
||||||
|
'>' => write!(w, ">")?,
|
||||||
|
c => write!(w, "{c}")?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_comment<W: fmt::Write>(w: &mut W, text: &str) -> Result<()> {
|
||||||
|
// A comment...
|
||||||
|
// - must not start with the string ">"
|
||||||
|
// - must not start with the string "->"
|
||||||
|
// - must not contain the strings "<!--", "-->", or "--!>"
|
||||||
|
// - must not end with the string "<!-"
|
||||||
|
//
|
||||||
|
// https://html.spec.whatwg.org/multipage/syntax.html#comments
|
||||||
|
|
||||||
|
let text = text
|
||||||
|
.replace("<!--", "<!==")
|
||||||
|
.replace("-->", "==>")
|
||||||
|
.replace("--!>", "==!>");
|
||||||
|
|
||||||
|
if text.starts_with(">") || text.starts_with("->") {
|
||||||
|
write!(w, " ")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
write!(w, "{text}")?;
|
||||||
|
|
||||||
|
if text.ends_with("<!-") {
|
||||||
|
write!(w, " ")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_attribute_value<W: fmt::Write>(w: &mut W, text: &str) -> Result<()> {
|
||||||
|
// Quoted attribute values are escaped like text, but the set of characters
|
||||||
|
// to escape is different.
|
||||||
|
//
|
||||||
|
// https://html.spec.whatwg.org/multipage/syntax.html#attributes-2
|
||||||
|
|
||||||
|
write!(w, "\"")?;
|
||||||
|
|
||||||
|
for c in text.chars() {
|
||||||
|
match c {
|
||||||
|
'"' => write!(w, """)?,
|
||||||
|
c => write!(w, "{c}")?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
write!(w, "\"")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue