This commit is contained in:
Logan 2024-03-10 08:51:30 -05:00
commit 5cef0b4563
17 changed files with 2339 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

1466
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

13
Cargo.toml Normal file
View file

@ -0,0 +1,13 @@
[package]
name = "html"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
html_parser = "0.7"
minify-html = "0.15"
grass = "0.13"
toml = "0.8"
walkdir = "2.5"

7
blueprint.html Normal file
View file

@ -0,0 +1,7 @@
<A>
<a href=@href>
<bold>
<tp:children />
</bold>
</a>
</A>

5
default.toml Normal file
View file

@ -0,0 +1,5 @@
[compile]
output="dist"
templates="tp"
source="src"
minify=false

0
index.html Normal file
View file

5
lg.toml Normal file
View file

@ -0,0 +1,5 @@
[compile]
output="dist"
templates="tp"
source="src"
minify=false

12
simple.html Normal file
View file

@ -0,0 +1,12 @@
<html lang="en">
<head>
<link>
<lg:include rel='css' href="./style.css" />
</head>
<body>
<A href="fdfs"> asdf </A>
</body>
</html>

0
src/README.md Normal file
View file

278
src/compiler.rs Normal file
View file

@ -0,0 +1,278 @@
use html_parser::{Dom, DomVariant, Element, ElementVariant, Node};
use std::{collections::HashMap, path::Path};
use crate::{
macros::*,
trace::{WithContext, *},
};
/// Replaces @variables in template with appropriate
/// values, or removes if not provided
fn init_vars(
node: Node,
attributes: &HashMap<String, Option<String>>,
children: &Vec<Node>,
) -> Node {
let mut element = match node {
Node::Text(_) | Node::Comment(_) => return node,
Node::Element(e) => e,
};
let mut new_classes = Vec::with_capacity(element.classes.len());
for cls in &mut element.classes {
if !cls.starts_with('@') {
new_classes.push(cls.clone());
continue;
}
let name = &cls[1..];
// Attributes in class must not be null
if let Some(Some(k)) = attributes.get(name) {
new_classes.push(k.clone());
}
// Otherwise discard variable from classes
}
element.classes = new_classes;
// Attributes in ID must not be null
if let Some(id) = &element.id {
if let Some(name) = id.strip_prefix('@') {
element.id = attributes.get(name).unwrap_or(&None).clone();
} else {
element.id = None;
}
}
let mut new_attributes = HashMap::new();
for (key, value) in &element.attributes {
if let Some(id) = value {
if let Some(variable_name) = id.strip_prefix('@') {
if let Some(value) = attributes.get(variable_name) {
// Insert null and non-null variables if key exists
new_attributes.insert(key.clone(), value.clone());
}
// Otherwise discard variable
}
// Insert non-null non-variable value
else {
new_attributes.insert(key.clone(), value.clone());
}
} else {
// Insert null non-variable value
new_attributes.insert(key.clone(), value.clone());
}
}
element.attributes = new_attributes;
let mut new_children = Vec::with_capacity(element.children.len());
for child in &element.children {
let name = child.element().map(|c| c.name.as_str()).unwrap_or("");
if "tp:children" == name {
for child in children {
new_children.push(child.clone());
}
} else {
let child = init_vars(child.clone(), attributes, children);
new_children.push(child);
}
}
element.children = new_children;
Node::Element(element)
}
/// Swaps out a template invocation for its definition
fn expand_templates(
invocation: Element,
templates: &Element,
) -> Result<Vec<Node>> {
for child in &invocation.children {
if child
.element()
.map(|c| c.name.as_str().strip_prefix("tp:").is_some())
.unwrap_or(false)
{
return Err(Error::new(
ErrorKind::Compilation,
"Illegal use of tp namespace",
));
}
for subchild in child {
if subchild
.element()
.map(|c| c.name.as_str().strip_prefix("tp:").is_some())
.unwrap_or(false)
{
return Err(Error::new(
ErrorKind::Compilation,
"Illegal use of tp namespace",
));
}
}
}
// Collect params
let mut attributes = invocation.attributes;
let classes = invocation.classes.join(" ");
if classes.len() != 0 {
attributes.insert("class".into(), Some(classes));
}
if let Some(id) = invocation.id {
attributes.insert("id".into(), Some(id));
}
// Swap params
let expanded = init_vars(
Node::Element(templates.clone()),
&attributes,
&invocation.children,
);
Ok(expanded.element().unwrap().children.clone())
}
/// Serializes HTML node
/// * `node` - Node to serialize
/// * `templates` - Map of templates by their names
fn node_to_string(
node: &Node,
templates: &HashMap<String, Element>,
macros: &HashMap<String, Macro>,
) -> Result<String> {
const OPEN: bool = false;
const CLOSE: bool = true;
let mut stack: Vec<(Node, bool)> = vec![(node.clone(), OPEN)];
let mut buf = String::new();
while let Some((current, closing)) = stack.pop() {
if closing == OPEN {
stack.push((current.clone(), CLOSE));
match current {
Node::Text(t) => {
buf.push_str(&t);
},
Node::Element(e) => {
// Expand if macro
if let Some(mc) = macros.get(&e.name) {
stack.pop().unwrap();
let expanded =
mc(&e.attributes).ctx(format!("Expanding macro: {}", e.name))?;
buf.push_str(&expanded);
continue;
}
// Expand if template
if let Some(tp) = templates.get(&e.name) {
let _ = stack.pop();
let elements = expand_templates(e.clone(), tp)
.ctx(format!("Expanding template: {}", e.name))?;
for element in elements.into_iter().rev() {
stack.push((element, OPEN));
}
continue;
}
// <
buf.push('<');
// Tag name
buf.push_str(&e.name);
// Classes
if !e.classes.is_empty() {
buf.push_str(&format!(" class='{}'", e.classes.join(" ")));
}
// ID
if let Some(id) = &e.id {
buf.push_str(&format!(" id='{}'", id));
}
// Attributes
for (k, v) in &e.attributes {
match v {
Some(v) => buf.push_str(&format!(" {}='{}'", k, v)),
None => buf.push_str(&format!(" {}", k)),
}
}
match e.variant {
ElementVariant::Normal => {
buf.push_str(">\n");
for child in e.children.iter().rev() {
stack.push((child.clone(), OPEN));
}
},
ElementVariant::Void => {
buf.push_str("/>\n");
let _ = stack.pop();
},
}
},
Node::Comment(_) => {},
}
} else {
match current {
Node::Text(_) => buf.push('\n'),
Node::Comment(_) => {},
Node::Element(e) => {
buf.push_str(&format!("</{}>\n", e.name));
},
}
}
}
Ok(buf)
}
pub struct Compiler {
templates: HashMap<String, Element>,
macros: HashMap<String, Macro>,
}
impl Compiler {
pub fn new() -> Self {
Self {
templates: HashMap::new(),
macros: default_macros(),
}
}
pub fn parse_templates_file(&mut self, path: impl AsRef<Path>) -> Result<()> {
let file = read_file(&path)?;
self.parse_templates(&file).ctx(format!(
"Parsing templates file: {}",
path.as_ref().to_string_lossy()
))?;
Ok(())
}
pub fn parse_templates(&mut self, html: &str) -> Result<()> {
let tps: HashMap<String, Element> = Dom::parse(html)?
.children
.into_iter()
.map(|i| i.element().cloned())
.flatten()
.map(|i| (i.name.clone(), i))
.collect();
if tps.is_empty() {
return Err(Error::new(ErrorKind::Parsing, "No blueprints found"));
}
self.templates.extend(tps);
Ok(())
}
pub fn compile_source_file(&self, path: impl AsRef<Path>) -> Result<String> {
let file = read_file(&path)?;
self.compile_source(&file).ctx(format!(
"Compiling source file: {}",
path.as_ref().to_string_lossy()
))
}
pub fn compile_source(&self, html: &str) -> Result<String> {
let dom = Dom::parse(html)?;
if !dom.errors.is_empty() {}
if dom.tree_type == DomVariant::Empty {
return Err(Error::new(ErrorKind::Parsing, "Empty DOM"));
}
if dom.tree_type != DomVariant::Document {
return Err(Error::new(
ErrorKind::Parsing,
"DOM must exactly have 1 root node",
));
}
let tree = &dom.children[0];
node_to_string(&tree, &self.templates, &self.macros)
}
}

32
src/macros.rs Normal file
View file

@ -0,0 +1,32 @@
use crate::trace::*;
use std::collections::HashMap;
pub fn default_macros() -> HashMap<String, Macro> {
let mut hm: HashMap<String, Macro> = HashMap::new();
hm.insert("lg:include".into(), INCLUDE_MACRO);
hm
}
pub type Macro = fn(&HashMap<String, Option<String>>) -> Result<String>;
const INCLUDE_MACRO: Macro = |input| {
let href = input
.get("href")
.ok_or(compile_error("'href' attribute missing"))?
.as_ref()
.ok_or(compile_error("'href' attribute is empty"))?;
let rel = input
.get("rel")
.ok_or(compile_error("'rel' attribute missing"))?
.as_ref()
.ok_or(compile_error("'rel' attribute is empty"))?;
let r = match rel.as_str() {
"css" => format!("<style>\n{}\n</style>\n", read_file(href)?),
"js" => format!("<script>\n{}\n</script>\n", read_file(href)?),
"inline" => read_file(href)?,
_ => return Err(compile_error(format!("Invalid 'rel' value: {}", rel))),
};
Ok(r)
};

81
src/main.rs Normal file
View file

@ -0,0 +1,81 @@
mod compiler;
mod macros;
mod parser;
mod trace;
use compiler::*;
use trace::*;
fn run_compiler() -> Result<()> {
// Read config file
let default_config = include_str!("../default.toml")
.parse::<toml::Table>()
.unwrap();
let user_config = std::fs::read_to_string("lg.toml")
.ctx("Reading config file")?
.parse::<toml::Table>()
.ctx("Parsing config file")?;
let mut config = default_config.clone();
config.extend(user_config);
let mut c = Compiler::new();
let compile_table = config["compile"]
.as_table()
.ctx("Reading compile options")?;
// Parse all templates
let templates_path = compile_table["templates"]
.as_str()
.ctx("Reading templates path")?;
let dirs = walkdir::WalkDir::new(templates_path)
.into_iter()
.flatten()
.filter_map(|v| {
if v.file_name().to_str().unwrap_or("").ends_with(".html")
&& v.file_type().is_file()
{
Some(v)
} else {
None
}
});
for file in dirs {
c.parse_templates_file(file.path())?;
}
// Compile all source files
let source_path = compile_table["source"]
.as_str()
.ctx("Reading templates path")?;
let dirs = walkdir::WalkDir::new(source_path)
.into_iter()
.flatten()
.filter_map(|v| {
if v.file_name().to_str().unwrap_or("").ends_with(".html")
&& v.file_type().is_file()
{
Some(v)
} else {
None
}
});
for file in dirs {}
// println!("{}", s);
Ok(())
}
fn main() {
let test = include_str!("../simple.html").to_string();
// let test = " <as> </as> ".to_string();
let r = parser::parse_html(&test);
for l in r {
println!("{l:?}");
}
// match run_compiler() {
// Ok(_) => {},
// Err(e) => {
// eprintln!("{}", e);
// std::process::exit(1);
// },
// }
}

199
src/parser.rs Normal file
View file

@ -0,0 +1,199 @@
use std::collections::HashMap;
use crate::trace::*;
#[derive(Clone, Debug)]
pub enum Lexeme<'a> {
OpenTag {
name: &'a str,
attributes: HashMap<&'a str, Option<&'a str>>,
is_void: bool,
},
CloseTag {
name: &'a str,
},
Content(&'a str),
}
fn normalize_whitespace(s: &str) {
// https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Whitespace
todo!()
}
fn error(message: impl Into<String>) -> Error {
Error {
kind: ErrorKind::Parsing,
reason: message.into(),
backtrace: vec![],
}
}
/// Try parsing single specific character
fn parse_char(i: &str, c: char) -> Option<(&str, &str)> {
if i.starts_with(c) {
Some((&i[0..1], &i[1..]))
} else {
None
}
}
// Parse until condition is not true for next character
fn parse_while(tail: &str, condition: impl Fn(char) -> bool) -> (&str, &str) {
let mut end;
let mut it = tail.char_indices();
'outer: loop {
match it.next() {
Some((i, c)) => {
end = i;
if !condition(c) {
break 'outer;
}
},
None => {
// Reached end of input
return (&tail, "");
},
};
}
(&tail[0..end], &tail[end..])
}
fn parse_whitespace(i: &str) -> (&str, &str) {
parse_while(i, |c| c.is_whitespace())
}
fn parse_doctype(tail: &str) -> Option<(&str, &str)> {
const doctype_str = "<!DOCTYPE>"
}
/// Try parsing all characters between two delimiter
/// characters
fn parse_delimited(i: &str, delimiter: char) -> Option<(&str, &str)> {
let (_, tail) = parse_char(i, delimiter)?;
let (value, tail) = parse_while(tail, |c| c != delimiter);
let (_, tail) = parse_char(tail, delimiter)?;
Some((value, tail))
}
fn parse_tag_name(i: &str) -> Option<(&str, &str)> {
let (value, tail) = parse_while(i, |c| c.is_ascii_alphanumeric() || c == ':');
if value.is_empty() {
None
} else {
Some((value, tail))
}
}
fn parse_attribute_key(i: &str) -> Option<(&str, &str)> {
let (value, tail) = parse_while(i, |c| {
!(['"', '\'', '>', '/', '='].contains(&c) || c.is_control())
});
if value.is_empty() {
None
} else {
Some((value, tail))
}
}
fn parse_attribute_val(i: &str) -> Option<(&str, &str)> {
const SINGLE_QUOTE: char = '\'';
const DOUBLE_QUOTE: char = '"';
let (value, tail) = parse_delimited(i, '\'') // Single quote delimit
.or_else(|| parse_delimited(i, '"')) // Double quote delimit
.or_else(|| { // Unquoted
Some(parse_while(i, |c| {
!(c.is_whitespace()
|| [SINGLE_QUOTE, DOUBLE_QUOTE, '=', '<', '>', '`'].contains(&c))
}))
})?;
if value.is_empty() {
None
} else {
Some((value, tail))
}
}
/// Returns Option<((key, value), tail)>
fn parse_key_val(tail: &str) -> Option<((&str, Option<&str>), &str)> {
// Require whitespace
let (ws, tail) = parse_whitespace(tail);
if ws.is_empty() {
return None;
}
// Fail when no key found
let (key, tail) = parse_attribute_key(tail)?;
let (_, tail) = parse_whitespace(tail);
if let Some((_, tail)) = parse_char(tail, '=') {
let (_, tail) = parse_whitespace(tail);
// Fail when = is not followed by value
let (val, tail) = parse_attribute_val(tail)?;
Some(((key, Some(val)), tail))
} else {
Some(((key, None), tail))
}
}
const VOID_ELEMENTS: [&str; 16] = [
"area", "base", "br", "col", "command", "embed", "hr", "img", "input",
"keygen", "link", "meta", "param", "source", "track", "wbr",
];
fn parse_open_tag(tail: &str) -> Option<(Lexeme, &str)> {
// <
let (_, tail) = parse_char(tail, '<')?;
// tag name
let (name, mut tail) = parse_tag_name(tail)?;
// attributes
let mut attributes: HashMap<&str, Option<&str>> = HashMap::new();
while let Some((kv, new_tail)) = parse_key_val(tail) {
attributes.insert(kv.0, kv.1);
tail = new_tail;
}
let (_, tail) = parse_whitespace(tail);
let (is_void, tail) = parse_char(tail, '/').unwrap_or(("", tail));
let is_void = !is_void.is_empty() || VOID_ELEMENTS.contains(&name);
let (_, tail) = parse_char(tail, '>')?;
Some((
Lexeme::OpenTag {
name,
attributes,
is_void,
},
tail,
))
}
fn parse_close_tag(tail: &str) -> Option<(Lexeme, &str)> {
let (_, tail) = parse_char(tail, '<')?;
let (_, tail) = parse_char(tail, '/')?;
let (name, tail) = parse_tag_name(tail)?;
let (_, tail) = parse_whitespace(tail);
let (_, tail) = parse_char(tail, '>')?;
Some((Lexeme::CloseTag { name }, tail))
}
fn parse_text(tail: &str) -> Option<(Lexeme, &str)> {
let (txt, tail) = parse_while(tail, |c| c != '<');
if txt.is_empty() {
None
} else {
Some((Lexeme::Content(txt), tail))
}
}
pub fn parse_html(mut tail: &str) -> Vec<Lexeme> {
let mut stack = vec![];
while !tail.is_empty() {
let (_, new_tail) = parse_whitespace(tail);
if new_tail.is_empty() {
break;
}
let (lm, new_tail) = parse_open_tag(new_tail)
.or_else(|| parse_close_tag(new_tail))
.or_else(|| parse_text(new_tail))
.unwrap();
stack.push(lm);
tail = new_tail;
}
stack
}

137
src/trace.rs Normal file
View file

@ -0,0 +1,137 @@
use std::{fmt::Display, path::Path};
pub type Result<T> = std::result::Result<T, Error>;
pub struct Error {
pub kind: ErrorKind,
pub reason: String,
pub backtrace: Vec<String>,
}
impl Error {
pub fn new(kind: ErrorKind, reason: impl Into<String>) -> Self {
Self {
kind,
reason: reason.into(),
backtrace: vec![],
}
}
pub fn msg(mut self, message: impl Into<String>) -> Self {
self.reason = message.into();
self
}
}
pub fn compile_error(reason: impl Into<String>) -> Error {
Error {
kind: ErrorKind::Compilation,
reason: reason.into(),
backtrace: vec![],
}
}
pub fn read_file(p: impl AsRef<Path>) -> Result<String> {
std::fs::read_to_string(&p)
.ctx(format!("Opening file: {}", p.as_ref().to_string_lossy()))
}
#[derive(Debug)]
pub enum ErrorKind {
IO,
Parsing,
Compilation,
Unknown,
}
impl Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{:?} error\nReason:\n\t{}\nBacktrace:\n",
self.kind, self.reason
)?;
for s in self.backtrace.iter().rev() {
write!(f, "\t{}\n", s)?;
}
Ok(())
}
}
impl std::fmt::Debug for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self)
}
}
impl From<std::io::Error> for Error {
fn from(value: std::io::Error) -> Self {
Self {
kind: ErrorKind::IO,
reason: format!("{}", value),
backtrace: vec![],
}
}
}
impl From<html_parser::Error> for Error {
fn from(value: html_parser::Error) -> Self {
match value {
html_parser::Error::Parsing(e) => Self {
kind: ErrorKind::Parsing,
reason: e,
backtrace: vec![],
},
html_parser::Error::Cli(e) => Self {
kind: ErrorKind::Unknown,
reason: e,
backtrace: vec![],
},
html_parser::Error::IO(e) => Self {
kind: ErrorKind::IO,
reason: format!("{}", e),
backtrace: vec![],
},
html_parser::Error::Serde(e) => Self {
kind: ErrorKind::Unknown,
reason: format!("{}", e),
backtrace: vec![],
},
}
}
}
impl From<toml::de::Error> for Error {
fn from(value: toml::de::Error) -> Self {
Error::new(ErrorKind::Parsing, value.message())
}
}
pub trait WithContext<T, S: Into<String>> {
fn ctx(self, s: S) -> Result<T>;
}
impl<T, E, S> WithContext<T, S> for std::result::Result<T, E>
where
E: Into<Error>,
S: Into<String>,
{
fn ctx(self, s: S) -> Result<T> {
self.map_err(|e| e.into()).map_err(|mut e| {
e.backtrace.push(s.into());
e
})
}
}
impl<T, S> WithContext<T, S> for std::option::Option<T>
where
S: Into<String>,
{
fn ctx(self, s: S) -> Result<T> {
match self {
Some(v) => Ok(v),
None => Err(Error::new(ErrorKind::Unknown, "Missing expected value")),
}
}
}

8
style.css Normal file
View file

@ -0,0 +1,8 @@
body {
background-color: linen;
}
h1 {
color: maroon;
margin-left: 40px;
}

91
test.html Normal file
View file

@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="test_files/main-ad0a5132b4027392.css">
<link rel="preload" href="https://logan-gatlin.com/web-2abb0afbb41a1a01_bg.wasm" as="fetch" type="application/wasm"
crossorigin="">
<link rel="modulepreload" href="https://logan-gatlin.com/web-2abb0afbb41a1a01.js">
<link
href="data:text/css,%3Ais(%5Bid*%3D'google_ads_iframe'%5D%2C%5Bid*%3D'taboola-'%5D%2C.taboolaHeight%2C.taboola-placeholder%2C%23credential_picker_container%2C%23credentials-picker-container%2C%23credential_picker_iframe%2C%5Bid*%3D'google-one-tap-iframe'%5D%2C%23google-one-tap-popup-container%2C.google-one-tap-modal-div%2C%23amp_floatingAdDiv%2C%23ez-content-blocker-container)%20%7Bdisplay%3Anone!important%3Bmin-height%3A0!important%3Bheight%3A0!important%3B%7D"
rel="stylesheet" type="text/css">
<link
href="data:text/css,%3Ais(%5Bid*%3D'google_ads_iframe'%5D%2C%5Bid*%3D'taboola-'%5D%2C.taboolaHeight%2C.taboola-placeholder%2C%23credential_picker_container%2C%23credentials-picker-container%2C%23credential_picker_iframe%2C%5Bid*%3D'google-one-tap-iframe'%5D%2C%23google-one-tap-popup-container%2C.google-one-tap-modal-div%2C%23amp_floatingAdDiv%2C%23ez-content-blocker-container)%20%7Bdisplay%3Anone!important%3Bmin-height%3A0!important%3Bheight%3A0!important%3B%7D"
rel="stylesheet" type="text/css">
</head>
<body>
<script type="module">import init from '/web-2abb0afbb41a1a01.js'; init('/web-2abb0afbb41a1a01_bg.wasm');</script>
<main class="container">
<article>
<header>
<nav aria-label="breadcrumb">
<ul>
<li><a href="https://logan-gatlin.com/">Home</a></li><!----><!---->
<li><a class="contrast">Base Conversion</a></li><!----><!---->
</ul>
</nav>
</header><!---->
<div class="grid">
<div>
<div><input type="text" placeholder="Decimal number" value="0"></div>
<table role="grid">
<tr>
<td role="button" type="button" class="secondary">HEX</td><!---->
<td>00<!----></td><!---->
</tr>
<tr>
<td role="button" type="button" class="secondary contrast">DEC</td><!---->
<td>0<!----></td><!---->
</tr>
<tr>
<td role="button" type="button" class="secondary">OCT</td><!---->
<td>000<!----></td><!---->
</tr>
<tr>
<td role="button" type="button" class="secondary">BIN</td><!---->
<td>0000 0000<!----></td><!---->
</tr>
</table>
<table>
<tr>
<td role="button" type="button" class="secondary contrast">BYTE</td><!---->
<td role="button" type="button" class="secondary">WORD</td><!---->
<td role="button" type="button" class="secondary">DWORD</td><!---->
<td role="button" type="button" class="secondary">QWORD</td><!---->
</tr>
</table><!----><!---->
</div>
<div>
<table class="binary">
<tr>
<td type="button" role="button" class="secondary">0<!----></td>
<td type="button" role="button" class="secondary">0<!----></td>
<td type="button" role="button" class="secondary">0<!----></td>
<td type="button" role="button" class="secondary">0<!----></td>
<td type="button" role="button" class="secondary">0<!----></td>
<td type="button" role="button" class="secondary">0<!----></td>
<td type="button" role="button" class="secondary">0<!----></td>
<td type="button" role="button" class="secondary">0<!----></td><!---->
</tr><!----><!----><!---->
</table><!---->
</div>
</div>
<hr>
<div><button type="button">Remember</button><!----><button type="button" class="secondary">Clear
history</button><!---->
<table><!----><!----></table><!---->
</div>
<footer><a href="https://github.com/Xterminate1818/based" target="_blank">View source code on GitHub</a><!---->
</footer>
</article><!----><!----><!---->
</main><!----><!----><!---->
</body>
</html>

File diff suppressed because one or more lines are too long