use crate::framework::ExtendedFrameworkConfiguration;
use crate::html::HtmlSource;
use crate::io::HtmlAsset;
use bevy::prelude::*;
use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
static TEMPLATE_NAME_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r#"template_name\s*:\s*"([^"]+)""#).unwrap());
static TEMPLATE_FILE_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r#"template_file\s*:\s*"([^"]+)""#).unwrap());
static STYLES_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r#"styles\s*:\s*&?\s*\[([^\]]*)\]"#).unwrap());
static QUOTED_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#""([^"]+)""#).unwrap());
static UI_COMPONENT_MARKER_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#"(?s)#\s*\[\s*(?:[\w:]+::)?ui_component(?:\s*\([^]]*\))?\s*\]"#).unwrap()
});
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComponentDefinition {
pub template_name: String,
pub template_file: String,
pub styles: Vec<String>,
pub source_dir_rel: String,
}
pub struct ExtendedComponentPlugin;
impl Plugin for ExtendedComponentPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, load_framework_index);
}
}
fn load_framework_index(
mut commands: Commands,
asset_server: Res<AssetServer>,
framework_config: Option<Res<ExtendedFrameworkConfiguration>>,
) {
let framework_config = framework_config.as_deref().cloned().unwrap_or_default();
if framework_config.index_html_file != "index.html" {
panic!(
"extended-framework requires `index.html` exactly. Found `{}`.",
framework_config.index_html_file
);
}
let index_path = PathBuf::from(&framework_config.asset_root_fs_path).join("index.html");
if !index_path.exists() {
panic!(
"extended-framework entrypoint missing: expected `{}`.",
index_path.display()
);
}
let handle: Handle<HtmlAsset> = asset_server.load("index.html");
commands.spawn(HtmlSource {
handle,
source_id: "framework-index".to_string(),
controller: None,
});
}
pub fn load_component_definitions(
config: &ExtendedFrameworkConfiguration,
) -> Result<Vec<ComponentDefinition>, String> {
let component_root = PathBuf::from(&config.rust_component_root);
if !component_root.exists() {
return Err(format!(
"Rust component root not found: `{}`",
component_root.display()
));
}
let mut component_files = Vec::new();
collect_component_rs_files(&component_root, &mut component_files).map_err(|err| {
format!(
"Failed to read component root `{}`: {err}",
component_root.display()
)
})?;
let mut definitions = Vec::new();
for component_file in component_files {
definitions.push(parse_component_definition_file(
&component_file,
&component_root,
)?);
}
Ok(definitions)
}
fn collect_component_rs_files(root: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
for entry in fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_component_rs_files(&path, out)?;
continue;
}
if let Some(name) = path.file_name().and_then(|name| name.to_str()) {
if name.ends_with(".component.rs") {
out.push(path);
}
}
}
Ok(())
}
fn parse_component_definition_file(
path: &Path,
root: &Path,
) -> Result<ComponentDefinition, String> {
let text = fs::read_to_string(path).map_err(|err| {
format!(
"Failed to read component definition `{}`: {err}",
path.display()
)
})?;
ensure_ui_component_macro_used(path, &text)?;
let template_name = required_captured_field(&TEMPLATE_NAME_RE, &text, "template_name", path)?;
let template_file = required_captured_field(&TEMPLATE_FILE_RE, &text, "template_file", path)?;
let styles = STYLES_RE
.captures(&text)
.and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()))
.map(|inner| {
QUOTED_RE
.captures_iter(&inner)
.filter_map(|caps| caps.get(1).map(|m| m.as_str().trim().to_string()))
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
})
.ok_or_else(|| format!("Missing required field `styles` in `{}`.", path.display()))?;
validate_component_name_contract(path, &template_file)?;
let source_dir_rel = path
.parent()
.and_then(|parent| parent.strip_prefix(root).ok())
.map(|p| normalize_path_like(p.to_string_lossy().as_ref()))
.unwrap_or_default();
Ok(ComponentDefinition {
template_name,
template_file,
styles,
source_dir_rel,
})
}
fn required_captured_field(
pattern: &Regex,
text: &str,
field_name: &str,
path: &Path,
) -> Result<String, String> {
pattern
.captures(text)
.and_then(|caps| caps.get(1).map(|m| m.as_str().trim().to_string()))
.filter(|value| !value.is_empty())
.ok_or_else(|| {
format!(
"Missing required field `{field_name}` in `{}`.",
path.display()
)
})
}
fn ensure_ui_component_macro_used(path: &Path, text: &str) -> Result<(), String> {
if UI_COMPONENT_MARKER_RE.is_match(text) {
return Ok(());
}
Err(format!(
"Missing required macro marker in `{}`. Add `#[bevy_extended_ui_macros::ui_component]` to the component definition.",
path.display()
))
}
fn validate_component_name_contract(path: &Path, template_file: &str) -> Result<(), String> {
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| format!("Invalid component path: `{}`", path.display()))?;
let expected_template = file_name
.strip_suffix(".rs")
.ok_or_else(|| format!("Invalid component filename: `{file_name}`"))?
.to_string()
+ ".html";
if expected_template != template_file {
return Err(format!(
"Component filename mismatch in `{}`. Expected `template_file: \"{}\"` but found `\"{}\"`.",
path.display(),
expected_template,
template_file
));
}
Ok(())
}
pub fn validate_component_assets(
definitions: &[ComponentDefinition],
config: &ExtendedFrameworkConfiguration,
) -> Result<(), String> {
let assets_root =
PathBuf::from(&config.asset_root_fs_path).join(trim_slashes(&config.assets_component_root));
let mut seen_tags = HashSet::new();
for definition in definitions {
if !seen_tags.insert(definition.template_name.clone()) {
return Err(format!(
"Duplicate template_name found: `{}`.",
definition.template_name
));
}
let template_path =
resolve_component_asset_candidate(&assets_root, definition, &definition.template_file);
if !template_path.exists() {
return Err(format!(
"Component template missing: `{}`.",
template_path.display()
));
}
for style_file in &definition.styles {
let style_path =
resolve_component_asset_candidate(&assets_root, definition, style_file);
if !style_path.exists() {
return Err(format!(
"Component style missing: `{}`.",
style_path.display()
));
}
}
}
Ok(())
}
pub fn load_component_template_html(
definition: &ComponentDefinition,
config: &ExtendedFrameworkConfiguration,
) -> Result<String, String> {
let assets_root =
PathBuf::from(&config.asset_root_fs_path).join(trim_slashes(&config.assets_component_root));
let template_path =
resolve_component_asset_candidate(&assets_root, definition, &definition.template_file);
fs::read_to_string(&template_path).map_err(|err| {
format!(
"Failed to read component html `{}`: {err}",
template_path.display()
)
})
}
fn trim_slashes(path: &str) -> String {
path.trim_matches('/').trim_matches('\\').to_string()
}
fn normalize_path_like(path: &str) -> String {
path.replace('\\', "/").trim_matches('/').to_string()
}
fn resolve_component_asset_candidate(
root: &Path,
definition: &ComponentDefinition,
file_name: &str,
) -> PathBuf {
let normalized_file_name = normalize_path_like(file_name);
if normalized_file_name.contains('/') {
return root.join(normalized_file_name);
}
if definition.source_dir_rel.is_empty() {
return root.join(normalized_file_name);
}
let scoped_path = root
.join(&definition.source_dir_rel)
.join(&normalized_file_name);
if scoped_path.exists() {
return scoped_path;
}
root.join(normalized_file_name)
}