use crate::lua::async_io::{AsyncIOTask, runtime};
use crate::tracker::SharedTracker;
use mlua::{Lua, Result, Table, Value};
use std::path::{Path, PathBuf};
pub fn create_module(lua: &Lua, project_root: &Path, tracker: SharedTracker) -> Result<Table> {
let seo_module = lua.create_table()?;
let root = project_root.to_path_buf();
let root_clone = root.clone();
let tracker_clone = tracker.clone();
let sitemap_fn = lua.create_function(move |lua, options: Table| {
let opts = SitemapOptions::from_lua(&options, lua)?;
let output = resolve_output_path(&opts.output, &root_clone);
let tracker = tracker_clone.clone();
let handle = runtime().spawn(async move {
let sitemap = generate_sitemap(&opts);
write_output_async(&output, &sitemap, &tracker).await
});
Ok(AsyncIOTask::new(handle))
})?;
seo_module.set("sitemap", sitemap_fn)?;
let root_clone = root.clone();
let tracker_clone = tracker.clone();
let robots_fn = lua.create_function(move |lua, options: Table| {
let opts = RobotsOptions::from_lua(&options, lua)?;
let output = resolve_output_path(&opts.output, &root_clone);
let tracker = tracker_clone.clone();
let handle = runtime().spawn(async move {
let robots = generate_robots(&opts);
write_output_async(&output, &robots, &tracker).await
});
Ok(AsyncIOTask::new(handle))
})?;
seo_module.set("robots", robots_fn)?;
Ok(seo_module)
}
#[derive(Debug, Clone)]
struct SitemapOptions {
base_url: String,
pages: Vec<SitemapPage>,
output: String,
default_changefreq: Option<String>,
default_priority: Option<f64>,
exclude: Vec<String>,
}
#[derive(Debug, Clone)]
struct SitemapPage {
path: String,
lastmod: Option<String>,
changefreq: Option<String>,
priority: Option<f64>,
}
impl SitemapOptions {
fn from_lua(options: &Table, _lua: &Lua) -> mlua::Result<Self> {
let base_url: String = options
.get("base_url")
.map_err(|_| mlua::Error::external("seo.sitemap: 'base_url' is required"))?;
let output: String = options
.get("output")
.map_err(|_| mlua::Error::external("seo.sitemap: 'output' is required"))?;
let default_changefreq: Option<String> = options.get("default_changefreq").ok();
let default_priority: Option<f64> = options.get("default_priority").ok();
let exclude = parse_string_array(options, "exclude");
let mut pages = Vec::new();
if let Ok(pages_table) = options.get::<Table>("pages") {
for (_, page_value) in pages_table.pairs::<i64, Value>().flatten() {
match page_value {
Value::String(s) => {
if let Ok(path) = s.to_str() {
pages.push(SitemapPage {
path: path.to_string(),
lastmod: None,
changefreq: None,
priority: None,
});
}
}
Value::Table(t) => {
if let Ok(path) = t.get::<String>("path") {
pages.push(SitemapPage {
path,
lastmod: t.get("lastmod").ok(),
changefreq: t.get("changefreq").ok(),
priority: t.get("priority").ok(),
});
}
}
_ => {}
}
}
}
Ok(Self {
base_url,
pages,
output,
default_changefreq,
default_priority,
exclude,
})
}
}
fn is_excluded(path: &str, exclude: &[String]) -> bool {
exclude.iter().any(|pattern| {
if pattern.contains('*') {
let regex_pattern = pattern.replace('.', r"\.").replace('*', ".*");
regex::Regex::new(&format!("^{}$", regex_pattern))
.map(|re| re.is_match(path))
.unwrap_or(false)
} else {
path == pattern || path.starts_with(pattern)
}
})
}
fn generate_sitemap(opts: &SitemapOptions) -> String {
let mut xml = String::new();
xml.push_str(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
xml.push('\n');
xml.push_str(r#"<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">"#);
xml.push('\n');
let base_url = opts.base_url.trim_end_matches('/');
for page in &opts.pages {
let path = if page.path.starts_with('/') {
page.path.clone()
} else {
format!("/{}", page.path)
};
if is_excluded(&path, &opts.exclude) {
continue;
}
xml.push_str(" <url>\n");
xml.push_str(&format!(" <loc>{}{}</loc>\n", base_url, path));
if let Some(lastmod) = &page.lastmod {
xml.push_str(&format!(" <lastmod>{}</lastmod>\n", lastmod));
}
let changefreq = page
.changefreq
.as_ref()
.or(opts.default_changefreq.as_ref());
if let Some(freq) = changefreq {
xml.push_str(&format!(" <changefreq>{}</changefreq>\n", freq));
}
let priority = page.priority.or(opts.default_priority);
if let Some(p) = priority {
xml.push_str(&format!(" <priority>{:.1}</priority>\n", p));
}
xml.push_str(" </url>\n");
}
xml.push_str("</urlset>\n");
xml
}
#[derive(Debug, Clone)]
struct RobotsOptions {
sitemap_url: Option<String>,
user_agent: String,
allow: Vec<String>,
disallow: Vec<String>,
output: String,
}
impl RobotsOptions {
fn from_lua(options: &Table, _lua: &Lua) -> mlua::Result<Self> {
let output: String = options
.get("output")
.map_err(|_| mlua::Error::external("seo.robots: 'output' is required"))?;
let sitemap_url: Option<String> = options.get("sitemap_url").ok();
let user_agent: String = options
.get("user_agent")
.unwrap_or_else(|_| "*".to_string());
let allow = parse_string_array(options, "allow");
let disallow = parse_string_array(options, "disallow");
Ok(Self {
sitemap_url,
user_agent,
allow,
disallow,
output,
})
}
}
fn parse_string_array(options: &Table, key: &str) -> Vec<String> {
options
.get::<Table>(key)
.ok()
.map(|t| {
t.pairs::<i64, String>()
.filter_map(|p| p.ok())
.map(|(_, v)| v)
.collect()
})
.unwrap_or_default()
}
fn generate_robots(opts: &RobotsOptions) -> String {
let mut content = String::new();
content.push_str(&format!("User-agent: {}\n", opts.user_agent));
for path in &opts.allow {
content.push_str(&format!("Allow: {}\n", path));
}
for path in &opts.disallow {
content.push_str(&format!("Disallow: {}\n", path));
}
if let Some(sitemap) = &opts.sitemap_url {
content.push('\n');
content.push_str(&format!("Sitemap: {}\n", sitemap));
}
content
}
fn resolve_output_path(output_path: &str, root: &Path) -> PathBuf {
if Path::new(output_path).is_absolute() {
PathBuf::from(output_path)
} else {
root.join(output_path)
}
}
async fn write_output_async(
output: &Path,
content: &str,
tracker: &SharedTracker,
) -> std::result::Result<(), String> {
if let Some(parent) = output.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| format!("Failed to create directory: {}", e))?;
}
tokio::fs::write(output, content)
.await
.map_err(|e| format!("Failed to write: {}", e))?;
let canonical = output
.canonicalize()
.unwrap_or_else(|_| output.to_path_buf());
tracker.record_write_async(canonical, content.as_bytes());
Ok(())
}