Compare commits

..

12 commits

Author SHA1 Message Date
gyoder
bfa5d30ae4 fixed parser bug 2025-07-21 12:35:51 -06:00
gyoder
a07d893e6e fixed parser bug 2025-07-21 12:26:51 -06:00
Grace Yoder
58bcf60567
Merge pull request #19 from gyoder/footnotes
Add import sorting options to .rustfmt.toml.
2025-07-13 12:08:15 -06:00
Quin
17759c3210 Sort imports with the help of rustfmt. 2025-07-12 23:56:50 -06:00
Quin
3632dd5e09 Add import sorting options to .rustfmt.toml. 2025-07-12 23:54:21 -06:00
Quin Gillespie
2991b10598
Merge pull request #17 from gyoder/footnotes
Added footnote support. Closes #16
2025-07-12 23:51:47 -06:00
Quin
11d35fe300 Fix the stupidest merge conflict in the history of git. 2025-07-12 23:51:11 -06:00
Quin Gillespie
8b631dd63c
Merge pull request #11 from gyoder/httpstdlib
Added Basic HTTP stdlib. Closes #10
2025-07-12 23:47:59 -06:00
gyoder
8dfaf0b386 Added footnote support 2025-07-12 22:54:46 -06:00
gyoder
5ed6fdebb4 Merge branch 'main' into httpstdlib 2025-07-12 22:02:53 -06:00
Grace Yoder
d9efc151c0
Merge pull request #15 from gyoder/config
Basic toml config support.
2025-07-12 19:43:55 -06:00
Quin
38be7b7365 Basic toml config support. 2025-07-11 16:55:49 -06:00
11 changed files with 383 additions and 32 deletions

View file

@ -9,3 +9,5 @@ brace_style = "PreferSameLine"
blank_lines_upper_bound = 5
combine_control_expr = false
wrap_comments = false
group_imports = "StdExternalCrate"
imports_granularity = "Crate"

136
Cargo.lock generated
View file

@ -225,6 +225,27 @@ dependencies = [
"syn 2.0.104",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.60.2",
]
[[package]]
name = "displaydoc"
version = "0.2.5"
@ -558,6 +579,7 @@ name = "htmlua-parser"
version = "0.1.0"
dependencies = [
"anyhow",
"dirs",
"html5ever 0.35.0",
"httptest",
"kuchikiki",
@ -569,6 +591,7 @@ dependencies = [
"serde_json",
"syntect",
"tendril",
"toml",
]
[[package]]
@ -919,6 +942,16 @@ version = "0.2.174"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776"
[[package]]
name = "libredox"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1580801010e535496706ba011c15f8532df6b42297d2e471fec38ceadd8c0638"
dependencies = [
"bitflags 2.9.1",
"libc",
]
[[package]]
name = "linked-hash-map"
version = "0.5.6"
@ -1209,6 +1242,12 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-float"
version = "2.10.1"
@ -1397,9 +1436,9 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plist"
version = "1.7.3"
version = "1.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "546b279bf0638ee811d9e47de2ca5b66575a543035d79fdf83959dd2f5c3b4c3"
checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1"
dependencies = [
"base64",
"indexmap 2.10.0",
@ -1474,9 +1513,9 @@ checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]]
name = "quick-xml"
version = "0.37.5"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
checksum = "8927b0664f5c5a98265138b7e3f90aa19a6b21353182469ace36d4ac527b7b1b"
dependencies = [
"memchr",
]
@ -1586,6 +1625,17 @@ dependencies = [
"bitflags 2.9.1",
]
[[package]]
name = "redox_users"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b"
dependencies = [
"getrandom 0.2.16",
"libredox",
"thiserror 2.0.12",
]
[[package]]
name = "regex"
version = "1.11.1"
@ -1865,6 +1915,15 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_spanned"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83"
dependencies = [
"serde",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@ -2023,7 +2082,7 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"thiserror",
"thiserror 1.0.69",
"walkdir",
"yaml-rust",
]
@ -2085,7 +2144,16 @@ version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
dependencies = [
"thiserror-impl 2.0.12",
]
[[package]]
@ -2099,6 +2167,17 @@ dependencies = [
"syn 2.0.104",
]
[[package]]
name = "thiserror-impl"
version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.104",
]
[[package]]
name = "time"
version = "0.3.41"
@ -2202,6 +2281,45 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed0aee96c12fa71097902e0bb061a5e1ebd766a6636bb605ba401c45c1650eac"
dependencies = [
"indexmap 2.10.0",
"serde",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3"
dependencies = [
"serde",
]
[[package]]
name = "toml_parser"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64"
[[package]]
name = "tower"
version = "0.5.2"
@ -2675,6 +2793,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
[[package]]
name = "winnow"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95"
[[package]]
name = "winsafe"
version = "0.0.19"

View file

@ -2,7 +2,9 @@
"words": [
"htmlua",
"headermap",
"reqwest"
"reqwest",
"includeelement",
"exportelement"
],
"userWords": [
"mlua"

View file

@ -1,6 +1,7 @@
use htmlua_parser::serve::serve_content;
use std::env;
use htmlua_parser::serve::serve_content;
fn main() {
println!("Content-Type: text/html\n");

View file

@ -5,6 +5,7 @@ edition = "2024"
[dependencies]
anyhow = "1.0.98"
dirs = "6.0.0"
html5ever = "0.35.0"
httptest = "0.16.3"
kuchikiki = "0.8.2"
@ -16,7 +17,7 @@ serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
syntect = "5.2.0"
tendril = "0.4.3"
toml = "0.9.2"
[lints.clippy]
pedantic = { level = "warn", priority = -1 }

100
htmlua-parser/src/config.rs Normal file
View file

@ -0,0 +1,100 @@
use std::{fs, path::PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Config {
pub paths: PathConfig,
pub server: ServerConfig,
pub syntax_highlighting: SyntaxConfig,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct PathConfig {
pub pages: PathBuf,
pub components: PathBuf,
pub themes: PathBuf,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct SyntaxConfig {
pub default_theme: String,
pub load_custom_themes: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
paths: PathConfig {
pages: PathBuf::from("/var/www/htmlua/pages"),
components: PathBuf::from("/var/www/htmlua/components"),
themes: PathBuf::from("/var/www/htmlua/themes"),
},
server: ServerConfig {
host: "127.0.0.1".to_string(),
port: 8080,
},
syntax_highlighting: SyntaxConfig {
default_theme: "base16-ocean.dark".to_string(),
load_custom_themes: true,
},
}
}
}
impl Config {
pub fn load() -> Result<Self> {
let config_path = Self::get_config_path();
if config_path.exists() {
let config_content = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
let config: Config = toml::from_str(&config_content)
.with_context(|| format!("Failed to parse config file: {}", config_path.display()))?;
Ok(config)
} else {
let default_config = Config::default();
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config directory: {}", parent.display()))?;
}
let config_content =
toml::to_string_pretty(&default_config).context("Failed to serialize default config")?;
fs::write(&config_path, config_content)
.with_context(|| format!("Failed to write default config to: {}", config_path.display()))?;
Ok(default_config)
}
}
fn get_config_path() -> PathBuf {
if cfg!(windows) {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData"))
.join("htmlua")
.join("config.toml")
} else {
PathBuf::from("/etc/htmlua.toml")
}
}
pub fn save(&self) -> Result<()> {
let config_path = Self::get_config_path();
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config directory: {}", parent.display()))?;
}
let config_content = toml::to_string_pretty(self).context("Failed to serialize config")?;
fs::write(&config_path, config_content)
.with_context(|| format!("Failed to write config to: {}", config_path.display()))?;
Ok(())
}
#[must_use]
pub fn config_file_path() -> PathBuf { Self::get_config_path() }
}

View file

@ -6,12 +6,10 @@ use std::{
use anyhow::Result;
use kuchikiki::NodeRef;
use markup5ever::{LocalName, Namespace, QualName};
use markup5ever::{LocalName, QualName, namespace_url, ns};
use tendril::TendrilSink;
pub fn read_doc_from_file(path: PathBuf) -> Result<NodeRef> {
let mut file = File::open(path)?;
let mut reader = BufReader::new(&mut file);
@ -29,7 +27,7 @@ pub fn read_doc_from_file(path: PathBuf) -> Result<NodeRef> {
if is_whole_doc {
Ok(kuchikiki::parse_html().one(page_string))
} else {
let ctx_name = QualName::new(None, Namespace::from("http://www.w3.org/1999/xhtml"), LocalName::from("div"));
let ctx_name = QualName::new(None, ns!(), LocalName::from("div"));
Ok(kuchikiki::parse_fragment(ctx_name, Vec::new()).one(page_string))
}
}

View file

@ -1,3 +1,4 @@
pub mod config;
pub mod helpers;
pub mod htmlua_stdlib;
pub mod render;

View file

@ -7,6 +7,7 @@ use std::{
use anyhow::{Result, anyhow};
use kuchikiki::{NodeRef, traits::TendrilSink};
use markup5ever::{LocalName, Namespace, QualName};
use mlua::Lua;
use pulldown_cmark::{Options, Parser, html};
use syntect::{
@ -17,7 +18,7 @@ use syntect::{
util::LinesWithEndings,
};
use crate::{helpers::read_doc_from_file, htmlua_stdlib::create_htmlua_stdlib};
use crate::{helpers::read_doc_from_file, htmlua_stdlib::create_htmlua_stdlib, serve::get_config};
fn build_lua_with_stdout(stdout: &Rc<RefCell<String>>) -> Result<Lua> {
@ -134,16 +135,15 @@ pub fn expand_template(document: NodeRef, component_path: &PathBuf, include_from
}
pub fn process_syntax_highlighting(document: NodeRef) -> Result<NodeRef> {
let config = get_config();
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);
if !syntax_elements.is_empty() && config.syntax_highlighting.load_custom_themes {
let _ = ts.add_from_folder(&config.paths.themes);
}
for node in syntax_elements {
let attrs = match node.as_node().as_element() {
@ -151,7 +151,7 @@ pub fn process_syntax_highlighting(document: NodeRef) -> Result<NodeRef> {
None => continue,
};
let language = attrs.get("lang").unwrap_or("text");
let theme_name = attrs.get("theme").unwrap_or("base16-ocean.dark");
let theme_name = attrs.get("theme").unwrap_or(&config.syntax_highlighting.default_theme);
if let Some(text_node) = node.as_node().first_child() {
if let Some(code_text) = text_node.as_text() {
let syntax = ps
@ -181,9 +181,41 @@ pub fn process_syntax_highlighting(document: NodeRef) -> Result<NodeRef> {
Ok(document)
}
pub fn generate_footnotes(document: NodeRef) -> Result<NodeRef> {
let Ok(footnote_container) = document.select_first("footnotecontainer") else {
return Ok(document);
};
let ctx_name = QualName::new(None, Namespace::from("http://www.w3.org/1999/xhtml"), LocalName::from("div"));
for (i, footnote) in document
.select("footnote")
.map_err(|()| anyhow!("Failed to get footnote"))?
.enumerate()
{
let i = i + 1;
let fn_text = footnote.text_contents();
let sup_tag = kuchikiki::parse_fragment(ctx_name.clone(), Vec::new())
.one(format!("<a href=#ft-text-{i}><sup id=\"ft-sup-{i}\" title=\"{fn_text}\">{i}</sup></a>"))
.select_first("a")
.map_err(|()| anyhow!("parse err"))?;
footnote.as_node().insert_after(sup_tag.as_node().clone());
let text_tag = kuchikiki::parse_fragment(ctx_name.clone(), Vec::new())
.one(format!("<p id=\"ft-text-{i}\"><a href=#ft-sup-{i}>{i}:</a> {fn_text}</p>"))
.select_first("p")
.map_err(|()| anyhow!("parse err"))?;
footnote_container.as_node().insert_before(text_tag.as_node().clone());
}
while let Ok(i) = document.select_first("footnote") {
i.as_node().detach();
}
footnote_container.as_node().detach();
Ok(document)
}
#[cfg(test)]
mod tests {
use httptest::{Expectation, ServerPool, matchers::*, responders::*};
use markup5ever::{namespace_url, ns};
use super::*;
@ -315,7 +347,6 @@ mod tests {
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();
@ -373,6 +404,74 @@ mod tests {
assert!(d.select_first("includeelement").is_err());
}
#[test]
fn include_base() {
let page = r#"
<include path="include_base.html">
<exportelement class="t"><title>title_test</title></exportelement>
<exportelement class="b"><span id="tb">body_el</span></exportelement>
</include>
"#;
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("tests/components");
let ctx_name = QualName::new(None, ns!(html), LocalName::from("div"));
let document = kuchikiki::parse_fragment(ctx_name, Vec::new()).one(page);
let d = expand_template(document, &p, None).unwrap();
let text = d
.select_first("head")
.unwrap()
.as_node()
.select_first("title")
.unwrap()
.as_node()
.text_contents();
assert_eq!(text, "title_test");
let text = d.select_first("#tb").unwrap().as_node().text_contents();
assert_eq!(text, "body_el");
assert!(d.select_first("exportelement").is_err());
assert!(d.select_first("includeelement").is_err());
}
#[test]
fn footnotes() {
let page = r"
<!DOCTYPE html>
<html>
<head>
<title>Basic HTML Page</title>
</head>
<body>
<div>
<p>asdf</p>
<p>asdf<footnote>um actually</footnote></p>
<p>asdf</p>
<p>asdf<footnote>no</footnote></p>
</div>
<div>
<footnotecontainer></footnotecontainer>
</div>
</body>
</html>";
let document = kuchikiki::parse_html().one(page);
let d = generate_footnotes(document).unwrap();
let sup1 = d.select_first("#ft-sup-1").unwrap();
assert_eq!(sup1.attributes.borrow().get("title").unwrap(), "um actually");
assert_eq!(sup1.text_contents(), "1");
let sup2 = d.select_first("#ft-sup-2").unwrap();
assert_eq!(sup2.attributes.borrow().get("title").unwrap(), "no");
assert_eq!(sup2.text_contents(), "2");
let ft_text_1 = d.select_first("#ft-text-1").unwrap();
assert!(ft_text_1.text_contents().contains("um actually"));
let ft_text_2 = d.select_first("#ft-text-2").unwrap();
assert!(ft_text_2.text_contents().contains("no"));
assert!(d.select_first("footnote").is_err());
assert!(d.select_first("footnotecontainer").is_err());
}
#[test]
fn basic_markdown() {
let page = r"

View file

@ -1,19 +1,33 @@
use crate::helpers::read_doc_from_file;
use std::{path::Path, sync::OnceLock};
use super::render::{execute_lua, expand_template, process_markdown, process_syntax_highlighting};
use anyhow::Result;
use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow};
use crate::{
config::Config,
helpers::read_doc_from_file,
render::{execute_lua, expand_template, process_markdown, process_syntax_highlighting},
};
static CONFIG: OnceLock<Config> = OnceLock::new();
pub fn get_config() -> &'static Config {
CONFIG.get_or_init(|| {
Config::load().unwrap_or_else(|e| {
eprintln!("Warning: Failed to load config: {e}");
eprintln!("Using default configuration");
Config::default()
})
})
}
pub fn serve_content(request_uri: &str) -> Result<String> {
// TODO: read from config
let pages = PathBuf::from("/var/www/htmlua/pages");
let components = PathBuf::from("/var/www/htmlua/components");
let config = get_config();
let safe_path = Path::new(request_uri).strip_prefix("/")?;
let page_path = pages.join(safe_path);
let doc = read_doc_from_file(page_path)?;
let full_doc = expand_template(doc, &components, None)?;
let page_path = config.paths.pages.join(safe_path);
let doc = read_doc_from_file(page_path)?
.select_first("html")
.map_err(|()| anyhow!("failed to read doc"))?;
let full_doc = expand_template(doc.as_node().to_owned(), &config.paths.components, None)?;
let markdown_doc = process_markdown(full_doc)?;
let highlighted_doc = process_syntax_highlighting(markdown_doc)?;
let executed_doc = execute_lua(highlighted_doc)?;

View file

@ -0,0 +1,9 @@
<html>
<head>
<includeelement name="t"></includeelement>
</head>
<body>
<h1>Hello World</h1>
<includeelement name="b"></includeelement>
</body>
</html>