htmlua/htmlua-parser/src/render.rs
2025-07-11 12:19:26 -06:00

416 lines
15 KiB
Rust

use std::{cell::RefCell, fmt::Write, fs::read_to_string, path::PathBuf, rc::Rc};
use anyhow::{Result, anyhow};
use kuchikiki::{NodeRef, traits::TendrilSink};
use mlua::{Lua, Table};
use pulldown_cmark::{Options, Parser, html};
use syntect::{
easy::HighlightLines,
highlighting::{Style, ThemeSet},
html::{IncludeBackground, styled_line_to_highlighted_html},
parsing::SyntaxSet,
util::LinesWithEndings,
};
use crate::{helpers::read_doc_from_file, htmlua_stdlib::create_htmlua_stdlib};
pub fn execute_lua(document: NodeRef) -> Result<NodeRef> {
let lua = Lua::new();
let globals = lua.globals();
let stdout: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
let htmlua_table =
create_htmlua_stdlib(&lua, &stdout).map_err(|e| anyhow!("Failed to create Lua stdlib: {}", e))?;
globals
.set("htmlua", htmlua_table)
.map_err(|e| anyhow!("Failed to set global: {}", e))?;
let lua_elements: Vec<_> = match document.select("lua") {
Ok(e) => e.collect(),
Err(()) => return Err(anyhow!("Unable to find Lua")),
};
for node in lua_elements {
if let Some(text_node) = node.as_node().first_child() {
if let Some(lua_code) = text_node.as_text() {
stdout.borrow_mut().clear();
lua.load(lua_code.borrow().as_str())
.exec()
.map_err(|e| anyhow!("Failed to execute Lua: {}", e))?;
node.as_node()
.insert_before(NodeRef::new_text(stdout.borrow().as_str()));
node.as_node().detach();
}
}
}
Ok(document)
}
pub fn process_markdown(document: NodeRef) -> Result<NodeRef> {
let markdown_elements: Vec<_> = match document.select("markdown") {
Ok(e) => e.collect(),
Err(()) => return Err(anyhow!("Unable to find markdown elements")),
};
for node in markdown_elements {
if let Some(text_node) = node.as_node().first_child() {
if let Some(markdown_text) = text_node.as_text() {
let borrowed_text = markdown_text.borrow();
let parser = Parser::new_ext(&borrowed_text, Options::all());
let mut html_output = String::new();
html::push_html(&mut html_output, parser);
let html_fragment = kuchikiki::parse_html().one(html_output);
for child in html_fragment.children() {
node.as_node().insert_before(child);
}
// Remove the original markdown node.
node.as_node().detach();
}
}
}
Ok(document)
}
pub fn expand_template(document: NodeRef, component_path: &PathBuf, include_from: Option<&NodeRef>) -> Result<NodeRef> {
if let Some(from_node) = include_from {
for i in document
.select("includeelement")
.map_err(|()| anyhow!("Error finding includeelement"))?
{
if let Some(name) = i.attributes.borrow().get("name") {
let exported = from_node
.select_first(format!("exportelement.{name}").as_str())
.map_err(|()| anyhow!("Error finding exportelement"))?;
exported.as_node().children().rev().for_each(|c| i.as_node().insert_after(c));
}
}
while let Ok(i) = document.select_first("includeelement") {
i.as_node().detach();
}
}
for i in document
.select("include")
.map_err(|()| anyhow!("Error finding include"))?
.collect::<Vec<_>>()
{
let attrs = match i.as_node().as_element() {
Some(e) => e.attributes.borrow(),
None => continue,
};
if let Some(include_path) = attrs.get("path") {
let mut item_path = component_path.clone();
item_path.push(include_path);
let new_node = read_doc_from_file(item_path)?;
let replaced_node = expand_template(new_node, component_path, Some(i.as_node()))?;
replaced_node.select_first("html").map_err(|()| anyhow!("Error finding html"))?.as_node().children().rev().for_each(|c| i.as_node().insert_after(c));
}
}
while let Ok(i) = document.select_first("include") {
i.as_node().detach();
}
Ok(document)
}
pub fn process_syntax_highlighting(document: NodeRef) -> Result<NodeRef> {
let ps = SyntaxSet::load_defaults_newlines();
let mut ts = ThemeSet::load_defaults();
let syntax_elements: Vec<_> = match document.select("syntaxhighlight") {
Ok(e) => e.collect(),
Err(()) => return Err(anyhow!("Unable to find syntaxhighlight elements")),
};
if !syntax_elements.is_empty() {
// TODO: read from config
let themes = PathBuf::from("/var/www/htmlua/themes");
let _ = ts.add_from_folder(themes);
}
for node in syntax_elements {
let attrs = match node.as_node().as_element() {
Some(e) => e.attributes.borrow(),
None => continue,
};
let language = attrs.get("lang").unwrap_or("text");
let theme_name = attrs.get("theme").unwrap_or("base16-ocean.dark");
if let Some(text_node) = node.as_node().first_child() {
if let Some(code_text) = text_node.as_text() {
let syntax = ps
.find_syntax_by_extension(language)
.or_else(|| ps.find_syntax_by_name(language))
.unwrap_or_else(|| ps.find_syntax_plain_text());
let theme = &ts.themes[theme_name];
let mut h = HighlightLines::new(syntax, theme);
let mut html_output = String::new();
write!(html_output, r#"<pre class="syntax-highlight" data-lang="{language}">"#)?;
html_output.push_str("<code>");
for line in LinesWithEndings::from(&code_text.borrow()) {
let ranges: Vec<(Style, &str)> = h.highlight_line(line, &ps)?;
let escaped = styled_line_to_highlighted_html(&ranges[..], IncludeBackground::No)?;
html_output.push_str(&escaped);
}
html_output.push_str("</code></pre>");
let html_fragment = kuchikiki::parse_html().one(html_output);
for child in html_fragment.children() {
node.as_node().insert_before(child);
}
// Remove the original syntaxhighlight node.
node.as_node().detach();
}
}
}
Ok(document)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_lua() {
let page = r#"
<!DOCTYPE html>
<html>
<head>
<title>Basic HTML Page</title>
</head>
<body>
<h1>Hello World</h1>
<p>This is a paragraph.</p>
<span><lua>
htmlua.println("Test from Lua!")
</lua></span>
</body>
</html>"#;
let document = kuchikiki::parse_html().one(page);
let d = execute_lua(document).unwrap();
let text = d.select_first("span").unwrap().as_node().text_contents();
assert_eq!(text, "Test from Lua!\n");
assert!(d.select_first("lua").is_err());
}
#[test]
fn multiple_lua() {
let page = r#"
<!DOCTYPE html>
<html>
<head>
<title>Basic HTML Page</title>
</head>
<body>
<h1>Hello World</h1>
<p>This is a paragraph.</p>
<span id="ta"><lua>
htmlua.println("Test from Lua!")
</lua></span>
<div>
<span id="tb"><lua>htmlua.println("test 2")</lua></span>
</div>
</body>
</html>"#;
let document = kuchikiki::parse_html().one(page);
let d = execute_lua(document).unwrap();
let text = d.select_first("#ta").unwrap().as_node().text_contents();
assert_eq!(text, "Test from Lua!\n");
let text = d.select_first("#tb").unwrap().as_node().text_contents();
assert_eq!(text, "test 2\n");
assert!(d.select_first("lua").is_err());
}
#[test]
fn basic_include() {
let page = r#"
<!DOCTYPE html>
<html>
<head>
<title>Basic HTML Page</title>
</head>
<body>
<h1>Hello World</h1>
<p>This is a paragraph.</p>
<include path="comp1.html" />
<span><lua>
htmlua.println("Test from Lua!")
</lua></span>
</body>
</html>"#;
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("tests/components");
let document = kuchikiki::parse_html().one(page);
let d = expand_template(document, &p, None).unwrap();
let text = d.select_first("span").unwrap().as_node().text_contents();
assert_eq!(text, "included1");
assert!(d.select_first("include").is_err());
}
#[test]
fn multiple_include() {
let page = r#"
<!DOCTYPE html>
<html>
<head>
<title>Basic HTML Page</title>
</head>
<body>
<h1>Hello World</h1>
<p>This is a paragraph.</p>
<include path="multi1_1.html"></include>
<include path="multi1_2.html"></include>
<include path="multi1_3.html"></include>
</body>
</html>"#;
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("tests/components");
let document = kuchikiki::parse_html().one(page);
let d = expand_template(document, &p, None).unwrap();
let text = d.select_first("#inc1").unwrap().as_node().text_contents();
assert_eq!(text, "included1");
let text = d.select_first("#inc2").unwrap().as_node().text_contents();
assert_eq!(text, "included2");
let text = d.select_first("#inc3").unwrap().as_node().text_contents();
assert_eq!(text, "included3");
assert!(d.select_first("include").is_err());
}
#[test]
fn basic_lua_include() {
let page = r#"
<!DOCTYPE html>
<html>
<head>
<title>Basic HTML Page</title>
</head>
<body>
<h1>Hello World</h1>
<p>This is a paragraph.</p>
<span><lua>
htmlua.println("Test from Lua!")
</lua></span>
<include path="comp1.html" />
</body>
</html>"#;
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("tests/components");
let document = kuchikiki::parse_html().one(page);
let d = execute_lua(expand_template(document, &p, None).unwrap()).unwrap();
let text = d.select_first("span").unwrap().as_node().text_contents();
println!("{}", d.to_string());
assert_eq!(text, "Test from Lua!\n");
assert!(d.select_first("lua").is_err());
let text = d.select_first("#inc1").unwrap().as_node().text_contents();
assert_eq!(text, "included1");
assert!(d.select_first("include").is_err());
}
#[test]
fn recursive_include() {
let page = r#"
<!DOCTYPE html>
<html>
<head>
<title>Basic HTML Page</title>
</head>
<body>
<h1>Hello World</h1>
<include path="comp2_level1.html" />
</body>
</html>"#;
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("tests/components");
let document = kuchikiki::parse_html().one(page);
let d = expand_template(document, &p, None).unwrap();
let text = d.select_first("span").unwrap().as_node().text_contents();
assert_eq!(text, "included_twice");
assert!(d.select_first("include").is_err());
}
#[test]
fn export_element_include() {
let page = r#"
<!DOCTYPE html>
<html>
<head>
<title>Basic HTML Page</title>
</head>
<body>
<h1>Hello World</h1>
<include path="with_include1.html">
<exportelement class="el1"><span id="ta">element 1</span></exportelement>
<exportelement class="el2"><span id="tb">element 2</span></exportelement>
</include>
</body>
</html>"#;
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("tests/components");
let document = kuchikiki::parse_html().one(page);
let d = expand_template(document, &p, None).unwrap();
let text = d.select_first("#ta").unwrap().as_node().text_contents();
assert_eq!(text, "element 1");
let text = d.select_first("#tb").unwrap().as_node().text_contents();
assert_eq!(text, "element 2");
assert!(d.select_first("exportelement").is_err());
assert!(d.select_first("includeelement").is_err());
}
#[test]
fn basic_markdown() {
let page = r"
<!DOCTYPE html>
<html>
<head>
<title>Markdown Test</title>
</head>
<body>
<h1>Hello World</h1>
<div>
<markdown>
# This is a heading
This is a paragraph with **bold text** and *italic text*.
- Item 1
- Item 2
- Item 3
</markdown>
</div>
</body>
</html>";
let document = kuchikiki::parse_html().one(page);
let d = process_markdown(document).unwrap();
assert!(d.select_first("markdown").is_err());
assert!(d.select_first("p").is_ok());
assert!(d.select_first("ul").is_ok());
}
#[test]
fn basic_syntax_highlighting() {
let page = r#"
<!DOCTYPE html>
<html>
<head>
<title>Syntax Highlighting Test</title>
</head>
<body>
<h1>Code Example</h1>
<syntaxhighlight lang="rust">
fn main() {
println!("Hello, world!");
let x = 42;
let y = "test";
}
</syntaxhighlight>
</body>
</html>"#;
let document = kuchikiki::parse_html().one(page);
let d = process_syntax_highlighting(document).unwrap();
assert!(d.select_first("syntaxhighlight").is_err());
assert!(d.select_first("pre").is_ok());
assert!(d.select_first("code").is_ok());
let pre = d.select_first("pre").unwrap();
let attrs = pre.attributes.borrow();
assert_eq!(attrs.get("data-lang"), Some("rust"));
assert!(attrs.get("class").unwrap().contains("syntax-highlight"));
}
}