commit ceb658bd34143bbebbd6879cd21929301eeee6ae
Author: Demonstrandum <moi@knutsen.co>
Date: Sun, 21 Jun 2020 04:25:17 +0100
Initial implementation.
Diffstat:
19 files changed, 803 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/Cargo.lock b/Cargo.lock
@@ -0,0 +1,73 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+[[package]]
+name = "atty"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
+dependencies = [
+ "hermit-abi",
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "colored"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4ffc801dacf156c5854b9df4f425a626539c3a6ef7893cc0c5084a23f0b6c59"
+dependencies = [
+ "atty",
+ "lazy_static",
+ "winapi",
+]
+
+[[package]]
+name = "hermit-abi"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9586eedd4ce6b3c498bc3b4dd92fc9f11166aa908a914071953768066c67909"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
+
+[[package]]
+name = "libc"
+version = "0.2.71"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9457b06509d27052635f90d6466700c65095fdf75409b3fbdd903e988b886f49"
+
+[[package]]
+name = "seam"
+version = "0.1.0"
+dependencies = [
+ "colored",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8093091eeb260906a183e6ae1abdba2ef5ef2257a21801128899c3fc699229c6"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
diff --git a/Cargo.toml b/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "seam"
+description = "Symbolic Expressions As Markup."
+keywords = ["markup", "lisp", "macro", "symbolic-expression", "sexp"]
+version = "0.1.0"
+authors = ["Demonstrandum <moi@knutsen.co>"]
+edition = "2018"
+
+
+[lib]
+name = "seam"
+path = "src/lib.rs"
+
+[[bin]]
+name = "seam"
+path = "src/bin.rs"
+
+[dependencies]
+colored = "1.8"
diff --git a/README.md b/README.md
@@ -0,0 +1,28 @@
+# SEAM
+
+> **S**ymbolic **E**xpressions **A**s **M**arkup.
+
+## Why
+
+Because all markup is terrible, especially XML/SGML and derivatives.
+
+But mainly, for easier static markup code generation, such as with
+macros, code includes and such.
+
+
+## Try it out
+
+Mainly this should be used as a library, such as from within a server,
+generating HTML (or any other supported markup) before it is served to the
+client.
+
+### Using The Binary
+
+(Providing you have cloned this repo, and `cd`'d into it)
+
+```console
+cargo run test.sex --html > test.html
+```
+
+`test.sex` contains your symbolic-expressions, which is used to generate
+HTML, saved in `test.html`.
diff --git a/samples/html-document-1.sex b/samples/html-document-1.sex
@@ -0,0 +1,10 @@
+(!DOCTYPE html)
+(html
+(head
+ (title Example HTML Document))
+(body
+ (p :id hello Hello, World!)
+ (p something text...)
+ (h1 "A (big) Header!")
+ (p Yet some more (span :style "color: red" text))))
+
diff --git a/samples/json-example.sex b/samples/json-example.sex
@@ -0,0 +1,27 @@
+(uuid "abc-123")
+(students [ ;; [...] means an array.
+ (
+ :id 0x000020f1 ;; Style 1
+ :name "Tohma"
+ :gender :male
+ )
+ ;; Parens loosly indicate an object grouping.
+ (
+ (id 0o00023721) ;; Style 2
+ (name "Julia")
+ (gender :female)
+ )
+]
+(techers [
+ ((id 123
+ (name "Bellers")
+ (gender :female)))])
+
+;; One can imagine that this document is implicitly
+;; wrapped in a pair of parenthesis.
+;; (This indicates it is an object grouping)
+;; If we explicitly use square brackets [...]
+;; at top-level, then that's a replacement for the
+;; implicit parenthesis. i.e. we've explicitly stated
+;; we want an array instead of an object (default).
+
diff --git a/samples/xml-example-1.sex b/samples/xml-example-1.sex
@@ -0,0 +1,7 @@
+(message :status urgernt
+ (to Tove)
+ (from Jani)
+ (heading A reminder \(Again!\) for you)
+ (body Don't forget me this weekend!))
+
+
diff --git a/samples/xml-example-2.sex b/samples/xml-example-2.sex
@@ -0,0 +1,26 @@
+(breakfast_menu
+ (food
+ (name Belgian\(-style\) Waffles)
+ (price $5.95)
+ (description
+ Two of our famous Belgian Waffles
+ with plenty of real maple syrup.)
+ (calories 650))
+ (food
+ (name "French(-style) Toast")
+ (price $4.50)
+ (description """
+ Thick slices made from our homemade
+ sourdough bread.
+ """)
+ (calories 600))
+ (food
+ (name "Home Breakfast")
+ (price "$6.95")
+ (desciption
+ Two eggs, bacon or sausage, toast,
+ and our ever-popular hash browns.)
+ (calories "950")))
+
+
+
diff --git a/src/assemble/html.rs b/src/assemble/html.rs
@@ -0,0 +1,123 @@
+//! Assembles an expanded tree into valid HTML.
+use super::Documentise;
+use crate::parse::parser::{ParseNode, ParseTree};
+
+use std::fmt::{self, Display};
+
+#[derive(Debug, Clone)]
+pub struct HTMLFormatter {
+ pub tree : ParseTree
+}
+
+impl HTMLFormatter {
+ pub fn new(tree : ParseTree) -> Self {
+ Self { tree }
+ }
+}
+
+pub const DEFAULT : &str =
+ "<!DOCTYPE>\n\
+ <html>\n\
+ <head></head>\n\
+ <body></body>\n\
+ </html>";
+
+impl Documentise for HTMLFormatter {
+ fn document(&self) -> String {
+ // Check if <!DOCTYPE html> exists.
+ let mut doc = String::new();
+ if self.tree.is_empty() {
+ return String::from(DEFAULT);
+ }
+ let mut current_node = &self.tree[0];
+ let mut has_declaration = false;
+
+ if let ParseNode::List(list) = ¤t_node {
+ if let Some(ParseNode::Symbol(declaration)) = &list.get(0) {
+ if declaration.value.to_lowercase() == "!doctype" {
+ has_declaration = true;
+ }
+ }
+ }
+
+ if has_declaration {
+ current_node = &self.tree[1];
+ } else {
+ doc += "<!DOCTYPE html>"
+ }
+ // Check if <html></html> root object exists.
+ // Check if head exits, if not, make an empty one.
+ // Check if body exists, if not, make it, and put everything
+ // in there.
+
+ doc += &self.to_string();
+
+ doc
+ }
+}
+
+
+// TODO: Convert special characters to HTML compatible ones.
+// e.g.
+// < => <
+// > => >
+// & => &
+// " => "
+// ! => !
+// etc.
+
+/// Converting the tree to an HTML string.
+impl Display for HTMLFormatter {
+ fn fmt(&self, f : &mut fmt::Formatter<'_>) -> fmt::Result {
+ for node in &self.tree {
+ match node {
+ ParseNode::Symbol(node) => write!(f, " {}", node.value)?,
+ ParseNode::Number(node) => write!(f, " {}", node.value)?,
+ ParseNode::String(node) => write!(f, " {}", node.value)?,
+ ParseNode::List(list) => {
+ let head = list.first();
+ let mut tag = "";
+ if let Some(head_node) = head {
+ if let ParseNode::Symbol(head_symbol) = head_node {
+ tag = &head_symbol.value;
+ write!(f, "<{}", tag)?;
+ } else {
+ // Error, tags can only have symbol values.
+ }
+ } else {
+ // Error, empty tags not supported.
+ }
+
+ let mut rest = &list[1..];
+
+ // Declarations behave differently.
+ if tag.as_bytes()[0] == '!' as u8 {
+ // TODO: Following can only be symbols.
+ while !rest.is_empty() {
+ write!(f, " {}", rest[0])?;
+ rest = &rest[1..];
+ }
+ write!(f, ">")?;
+ continue;
+ }
+
+ while let Some(ParseNode::Attribute(attr)) = rest.first() {
+ if let Some(atom) = (*attr.node).atomic() {
+ write!(f, " {}=\"{}\"", attr.keyword, atom.value)?;
+ rest = &rest[1..];
+ } else {
+ // Error! Cannot be non atomic.
+ }
+ }
+ writeln!(f, ">")?;
+
+ let html_fmt = HTMLFormatter::new(rest.to_owned());
+ writeln!(f, "{}", html_fmt)?;
+ write!(f, "</{}>", tag)?;
+ },
+ _ => write!(f, "hi")?,
+ }
+ }
+ write!(f, "")
+ }
+}
diff --git a/src/assemble/mod.rs b/src/assemble/mod.rs
@@ -0,0 +1,5 @@
+pub trait Documentise {
+ fn document(&self) -> String;
+}
+
+pub mod html;
diff --git a/src/bin.rs b/src/bin.rs
@@ -0,0 +1,66 @@
+use seam;
+use seam::assemble::Documentise;
+
+use std::env;
+use std::path::PathBuf;
+use std::error::Error;
+
+use colored::*;
+
+fn argument_fatal(msg : &str) -> ! {
+ eprintln!("{} {}",
+ format!("[{}]", "**".red()).white().bold(),
+ msg.bold());
+ std::process::exit(1)
+}
+
+const SUPPORTED_TARGETS : [&str; 1] = ["html"];
+
+fn main() -> Result<(), Box<dyn Error>> {
+ let (major, minor, tiny) = seam::VERSION;
+ eprintln!("{}", format!("SEAM v{}.{}.{}",
+ major, minor, tiny).bold());
+
+ let mut args = env::args();
+ args.next(); // Discard.
+
+ let mut files = Vec::new();
+ let mut target = "";
+
+ for arg in args {
+ if let Some(opt) = arg.split("--").nth(1) {
+ if SUPPORTED_TARGETS.contains(&opt) {
+ target = Box::leak(opt.to_owned().into_boxed_str());
+ }
+ continue;
+ }
+ let path = PathBuf::from(&arg);
+ if path.exists() {
+ eprintln!("Reading file `{}'.", &path.display());
+ files.push(path);
+ }
+ }
+
+ if files.is_empty() {
+ argument_fatal("No input files given.");
+ }
+ if target.is_empty() {
+ argument_fatal("No such target exists / no target given.");
+ }
+
+ for file in files {
+ let tree = seam::parse_file(&file)?;
+ /*eprintln!("{}", &tree
+ .iter().fold(String::new(),
+ |acc, s| acc + "\n" + &s.to_string()));*/
+ if target == "html" {
+ let fmt = seam::assemble::html::HTMLFormatter::new(tree);
+ let result = fmt.document();
+ println!("{}", result);
+ }
+ }
+
+ eprintln!("All files read and converted.");
+
+ Ok(())
+}
diff --git a/src/lib.rs b/src/lib.rs
@@ -0,0 +1,27 @@
+pub mod parse;
+pub mod assemble;
+
+use parse::{parser, lexer};
+
+use std::error::Error;
+use std::{fs, path::Path};
+
+pub const VERSION : (u8, u8, u8) = (0, 1, 0);
+
+pub fn parse<P: AsRef<Path>>(string : String, source : Option<P>)
+ -> Result<parser::ParseTree, Box<dyn Error>> {
+ let tokens = lexer::lex(string, source)?;
+ let tree = parser::parse_stream(tokens)?;
+ Ok(tree)
+}
+
+pub fn parse_file(path : &Path)
+ -> Result<parser::ParseTree, Box<dyn Error>> {
+ let contents = fs::read_to_string(&path)?;
+ parse(contents, Some(&path))
+}
+
+pub fn main() {
+ eprintln!("Library main function should not be used.");
+ std::process::exit(1);
+}
diff --git a/src/parse/expander.rs b/src/parse/expander.rs
diff --git a/src/parse/lexer.rs b/src/parse/lexer.rs
@@ -0,0 +1,146 @@
+use super::tokens::{self, Token, TokenStream};
+
+use std::path::Path;
+use std::{fmt, error::Error};
+
+#[derive(Debug, Clone)]
+pub struct LexError(Token, String);
+
+impl fmt::Display for LexError {
+ fn fmt(&self, f : &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "[**] Lexical Error: `{}'.\nAt: {:#?}",
+ self.1, self.0)
+ }
+}
+
+impl Error for LexError { }
+
+fn character_kind(character : char, prev : Option<tokens::Kind>)
+ -> Option<tokens::Kind> {
+ let kind = match character {
+ '\n' | '\r' | ' ' | '\t' => None,
+ '(' => Some(tokens::Kind::LParen),
+ ')' => Some(tokens::Kind::RParen),
+ '0'..='9' => Some(tokens::Kind::Number),
+ ':' => Some(tokens::Kind::Keyword),
+ '"' => Some(tokens::Kind::String),
+ _ => Some(tokens::Kind::Symbol)
+ };
+
+ if prev == Some(tokens::Kind::String) {
+ if character == '"' {
+ None
+ } else {
+ prev
+ }
+ } else {
+ kind
+ }
+}
+
+pub fn lex<P: AsRef<Path>>(string : String, _source : Option<P>)
+ -> Result<TokenStream, LexError> {
+
+ let eof = string.len();
+ let mut lines : usize = 1;
+ let mut bytes : usize = 0;
+ let mut line_bytes : usize = 0;
+
+ let mut accumulator : Vec<u8> = Vec::new();
+ let mut tokens = Vec::new();
+
+ let mut token_start : usize = 0;
+ let mut current_kind = None;
+ let mut old_kind = None;
+
+ while bytes < eof {
+ let current_byte = string.as_bytes()[bytes];
+
+ if !string.is_char_boundary(bytes) {
+ accumulator.push(current_byte);
+ bytes += 1;
+ line_bytes += 1;
+ continue;
+ }
+
+ let character = current_byte as char;
+
+ let mut prev_kind = current_kind;
+ current_kind = character_kind(character, current_kind);
+
+ let string_start = character == '"'
+ && prev_kind != Some(tokens::Kind::String);
+ if string_start {
+ current_kind = None;
+ }
+
+ let mut peek_kind = if bytes == eof - 1 {
+ None
+ } else {
+ let peek_char = string.as_bytes()[bytes + 1] as char;
+ character_kind(peek_char, current_kind)
+ };
+
+ let was_lparen = current_kind == Some(tokens::Kind::LParen);
+ let was_rparen = current_kind == Some(tokens::Kind::RParen);
+ let peek_rparen = peek_kind == Some(tokens::Kind::RParen);
+ if was_lparen || was_rparen {
+ peek_kind = None;
+ prev_kind = None;
+ }
+ if peek_rparen {
+ peek_kind = None;
+ }
+
+ if let Some(kind_current) = current_kind {
+ if prev_kind.is_none() {
+ old_kind = current_kind;
+ token_start = line_bytes;
+ }
+ accumulator.push(current_byte);
+ bytes += 1;
+ line_bytes += 1;
+
+ if peek_kind.is_none() {
+ let kind = if let Some(kind_old) = old_kind {
+ kind_old
+ } else {
+ kind_current
+ };
+
+ let mut span = accumulator.len();
+ if kind == tokens::Kind::String {
+ span += 2;
+ }
+
+ let value = String::from_utf8(accumulator).unwrap();
+ let site = tokens::Site::from_line(lines, token_start, span);
+ tokens.push(Token::new(kind, value, site));
+ accumulator = Vec::new();
+
+ if was_lparen || peek_rparen || was_rparen {
+ old_kind = None;
+ current_kind = None;
+ token_start = line_bytes;
+ }
+
+ }
+ } else {
+ bytes += 1;
+ line_bytes += 1;
+ }
+
+ if character == '\n' {
+ line_bytes = 0;
+ token_start = 0;
+ lines += 1;
+ }
+ if string_start {
+ current_kind = Some(tokens::Kind::String);
+ old_kind = current_kind;
+ token_start = line_bytes - 1;
+ }
+ }
+
+ Ok(tokens)
+}
diff --git a/src/parse/mod.rs b/src/parse/mod.rs
@@ -0,0 +1,7 @@
+pub mod tokens;
+
+pub mod lexer;
+
+pub mod parser;
+
+pub mod expander;
diff --git a/src/parse/parser.rs b/src/parse/parser.rs
@@ -0,0 +1,148 @@
+use std::{fmt, error::Error};
+use super::tokens::{self, Kind, Site, Token};
+
+#[derive(Debug, Clone)]
+pub struct Node {
+ pub site : Site,
+ pub value : String
+}
+
+impl Node {
+ pub fn new(value : &str, site : &Site) -> Self {
+ Self {
+ site: site.to_owned(),
+ value: value.to_owned()
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct AttributeNode {
+ pub keyword : String,
+ pub node : Box<ParseNode>
+}
+
+#[derive(Debug, Clone)]
+pub enum ParseNode {
+ Symbol(Node),
+ Number(Node),
+ String(Node),
+ List(Vec<ParseNode>),
+ Attribute(AttributeNode)
+}
+
+impl ParseNode {
+ pub fn atomic(&self) -> Option<Node> {
+ match self {
+ Self::Symbol(node)
+ | Self::Number(node)
+ | Self::String(node) => Some(node.to_owned()),
+ _ => None
+ }
+ }
+}
+
+pub type ParseTree = Vec<ParseNode>;
+
+#[derive(Debug, Clone)]
+pub struct ParseError(pub String, pub Site);
+
+impl fmt::Display for ParseError {
+ fn fmt(&self, f : &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "[**] Parse Error: `{}',\nAt: {:#?}",
+ self.0, self.1)
+ }
+}
+
+impl Error for ParseError { }
+
+fn parse_atomic(token : &Token) -> Result<ParseNode, ParseError> {
+ let node = Node::new(&token.value, &token.site);
+ match token.kind {
+ Kind::Symbol => Ok(ParseNode::Symbol(node)),
+ Kind::String => Ok(ParseNode::String(node)),
+ Kind::Number => Ok(ParseNode::Number(node)),
+ _ => Err(ParseError(
+ String::from("Atomic token not found here."),
+ token.site.clone()))
+ }
+}
+
+pub fn parse(tokens : &[Token])
+ -> Result<(ParseNode, &[Token]), ParseError> {
+ let token = &tokens[0];
+ match token.kind {
+ Kind::LParen => {
+ // Parse list.
+ let mut slice = &tokens[1..];
+ let mut elements = Vec::new();
+ let mut token = &slice[0];
+ loop {
+ if slice.is_empty() {
+ return Err(ParseError(
+ "Expected `)' (closing parenthesis), got EOF."
+ .to_owned(), token.site.clone()));
+ }
+ token = &slice[0];
+ if token.kind == Kind::RParen
+ { break; }
+
+ let (element, left) = parse(&slice)?;
+ elements.push(element);
+ slice = left;
+ }
+ slice = &slice[1..]; // Ignore last r-paren.
+ Ok((ParseNode::List(elements), slice))
+ },
+ Kind::Keyword => {
+ // Parse second token, make attribute.
+ let (node, slice) = parse(&tokens[1..])?;
+ let attribute = AttributeNode {
+ keyword: token.value[1..].to_owned(),
+ node: Box::new(node)
+ };
+ Ok((ParseNode::Attribute(attribute), slice))
+ },
+ Kind::RParen => {
+ Err(ParseError("Unexpected `)' (closing parenthesis). \
+ Perhaps you forgot an opening parenthesis?".to_owned(),
+ token.site.clone()))
+ },
+ _ => { // Any atomic tokens.
+ Ok((parse_atomic(&token)?, &tokens[1..]))
+ }
+ }
+}
+
+pub fn parse_stream(tokens: tokens::TokenStream)
+ -> Result<ParseTree, ParseError> {
+ let mut tree = Vec::new();
+ let mut slice = &tokens[..];
+ while !slice.is_empty() {
+ let (node, next) = parse(slice)?;
+ tree.push(node);
+ slice = next;
+ }
+ Ok(tree)
+}
+
+/// Pretty printing for parse nodes.
+impl fmt::Display for ParseNode {
+ fn fmt(&self, f : &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ ParseNode::Symbol(node)
+ | ParseNode::Number(node) => write!(f, "{}", &node.value),
+ ParseNode::String(node) => write!(f, "\"{}\"", &node.value),
+ ParseNode::Attribute(attr) => write!(f, ":{} {}",
+ &attr.keyword, &*attr.node),
+ ParseNode::List(list) => write!(f, "({}{})", &list[0],
+ list[1..].iter().fold(String::new(), |acc, elem| {
+ let nested = elem.to_string().split('\n')
+ .fold(String::new(), |acc, e|
+ acc + "\n " + &e);
+ acc + &nested
+ }))
+ }
+ }
+}
+
diff --git a/src/parse/tokens.rs b/src/parse/tokens.rs
@@ -0,0 +1,54 @@
+#[derive(Debug, Clone)]
+pub struct Site {
+ pub source : Option<String>,
+ pub line : usize,
+ pub bytes_from_start : usize,
+ pub bytes_span : usize,
+}
+
+impl Site {
+ pub fn new(source : String, line : usize,
+ bytes_from_start : usize,
+ bytes_span : usize) -> Self {
+ Self {
+ source: Some(source),
+ line, bytes_from_start,
+ bytes_span
+ }
+ }
+
+ pub fn from_line(line : usize,
+ bytes_from_start : usize,
+ bytes_span : usize) -> Self {
+ Self {
+ source: None,
+ line, bytes_from_start,
+ bytes_span
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum Kind {
+ LParen,
+ RParen,
+ Symbol,
+ String,
+ Number,
+ Keyword,
+}
+
+#[derive(Debug, Clone)]
+pub struct Token {
+ pub kind : Kind,
+ pub value : String,
+ pub site : Site,
+}
+
+impl Token {
+ pub fn new(kind : Kind, value : String, site : Site) -> Self {
+ Self { kind, value, site }
+ }
+}
+
+pub type TokenStream = Vec<Token>;
diff --git a/test.html b/test.html
@@ -0,0 +1,21 @@
+<!DOCTYPE html><html>
+<head>
+<title>
+ Example HTML Document
+</title>
+</head><body>
+<p id="hello">
+ Hello, World!
+</p><p>
+ something something text...
+</p><h1>
+ A (big) Header!
+</h1><p>
+ Yet some more<span style="color: red">
+ text
+</span> <3
+</p><img alt="Cute Cat" src="https://static.insider.com/image/5d24d6b921a861093e71fef3.jpg" width="300">
+
+</img>
+</body>
+</html>
diff --git a/test.sex b/test.sex
@@ -0,0 +1,15 @@
+(!DOCTYPE html)
+(html
+ (head
+ (title Example HTML Document))
+ (body
+ (p :id hello Hello, World!)
+ (p something something text...)
+ (h1 "A (big) Header!")
+ (p Yet some more
+ (span :style "color: red" text) <3)
+ (img
+ :alt "Cute Cat"
+ :src "https://static.insider.com/image/5d24d6b921a861093e71fef3.jpg"
+ :width 300)))
+