diff --git a/Cargo.lock b/Cargo.lock index 0c524704..44aca8d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3429,6 +3429,7 @@ dependencies = [ "iana-time-zone", "insta", "ironclaw_common", + "ironclaw_frontend", "ironclaw_safety", "json5", "libsql", @@ -3496,6 +3497,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "ironclaw_frontend" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "ironclaw_safety" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 0382a2a7..adf74389 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = [".", "crates/ironclaw_common", "crates/ironclaw_safety"] +members = [".", "crates/ironclaw_common", "crates/ironclaw_safety", "crates/ironclaw_frontend"] exclude = [ "channels-src/discord", "channels-src/telegram", @@ -105,6 +105,7 @@ cron = "0.13" ironclaw_common = { path = "crates/ironclaw_common", version = "0.1.0" } # Safety/sanitization +ironclaw_frontend = { path = "crates/ironclaw_frontend", version = "0.1.0" } ironclaw_safety = { path = "crates/ironclaw_safety", version = "0.2.0" } regex = "1" aho-corasick = "1" diff --git a/crates/ironclaw_frontend/Cargo.toml b/crates/ironclaw_frontend/Cargo.toml new file mode 100644 index 00000000..b8e8aa15 --- /dev/null +++ b/crates/ironclaw_frontend/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ironclaw_frontend" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +description = "Frontend assets, layout configuration, and widget extension system for IronClaw" + +[package.metadata.dist] +dist = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" diff --git a/crates/ironclaw_frontend/src/assets.rs b/crates/ironclaw_frontend/src/assets.rs new file mode 100644 index 00000000..78fea26d --- /dev/null +++ b/crates/ironclaw_frontend/src/assets.rs @@ -0,0 +1,37 @@ +//! Embedded static assets for the IronClaw web gateway. +//! +//! All frontend files are compiled into the binary via `include_str!()` / +//! `include_bytes!()`. The web gateway serves these as the default baseline; +//! workspace-stored customizations (layout config, widgets, CSS overrides) +//! are layered on top at runtime. + +// ==================== Core Files ==================== + +/// Main HTML page (SPA shell). +pub const INDEX_HTML: &str = include_str!("../static/index.html"); + +/// Main application JavaScript. +pub const APP_JS: &str = include_str!("../static/app.js"); + +/// Base stylesheet. +pub const STYLE_CSS: &str = include_str!("../static/style.css"); + +/// Theme initialization script (runs synchronously in `
` to prevent FOUC). +pub const THEME_INIT_JS: &str = include_str!("../static/theme-init.js"); + +/// Favicon. +pub const FAVICON_ICO: &[u8] = include_bytes!("../static/favicon.ico"); + +// ==================== Internationalization ==================== + +/// i18n core library. +pub const I18N_INDEX_JS: &str = include_str!("../static/i18n/index.js"); + +/// English translations. +pub const I18N_EN_JS: &str = include_str!("../static/i18n/en.js"); + +/// Chinese (Simplified) translations. +pub const I18N_ZH_CN_JS: &str = include_str!("../static/i18n/zh-CN.js"); + +/// i18n integration with the app. +pub const I18N_APP_JS: &str = include_str!("../static/i18n-app.js"); diff --git a/crates/ironclaw_frontend/src/bundle.rs b/crates/ironclaw_frontend/src/bundle.rs new file mode 100644 index 00000000..5e25e1a2 --- /dev/null +++ b/crates/ironclaw_frontend/src/bundle.rs @@ -0,0 +1,240 @@ +//! Frontend bundle assembly. +//! +//! Combines the embedded base HTML with workspace customizations (layout +//! config, widgets, CSS overrides) into the final served page. + +use crate::layout::LayoutConfig; +use crate::widget::{WidgetManifest, scope_css}; + +/// A resolved frontend bundle ready for serving. +/// +/// Contains the layout configuration, resolved widgets (with their JS/CSS +/// content loaded), and any custom CSS overrides. +#[derive(Debug, Clone, Default)] +pub struct FrontendBundle { + /// Layout configuration (branding, tabs, chat settings). + pub layout: LayoutConfig, + + /// Resolved widgets with their source code loaded. + pub widgets: Vec`:** +/// - Layout config as `window.__IRONCLAW_LAYOUT__` +/// - Scoped widget `", css_vars)); + } + + // --- Body injections --- + + // Layout config as global variable + if let Ok(layout_json) = serde_json::to_string(&bundle.layout) { + body_injections.push(format!( + "", + layout_json + )); + } + + // Widget CSS (scoped) and JS + for widget in &bundle.widgets { + if let Some(ref css) = widget.css { + let scoped = scope_css(css, &widget.manifest.id); + if !scoped.trim().is_empty() { + body_injections.push(format!( + "", + widget.manifest.id, scoped + )); + } + } + + // Widget JS as module script served from API + body_injections.push(format!( + "", + widget.manifest.id + )); + } + + // Custom CSS + if let Some(ref custom_css) = bundle.custom_css { + if !custom_css.trim().is_empty() { + body_injections.push(format!( + "", + custom_css + )); + } + } + + // --- Assemble --- + + let mut result = base_html.to_string(); + + // Inject before + if !head_injections.is_empty() { + let head_block = head_injections.join("\n"); + if let Some(pos) = result.rfind("") { + result.insert_str(pos, &format!("\n{}\n", head_block)); + } + } + + // Override
".len(); + result.replace_range(start..end, &format!("
", title)); + } + } + } + + // Inject before + if !body_injections.is_empty() { + let body_block = body_injections.join("\n"); + if let Some(pos) = result.rfind("") { + result.insert_str(pos, &format!("\n{}\n", body_block)); + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::layout::*; + use crate::widget::*; + + const MINIMAL_HTML: &str = + "