use serde_json::Value;
use std::collections::HashSet;
use crate::plugin::{collect_plugin_assets, with_plugin, Asset};
use crate::spec::{Spec, MAX_NESTING_DEPTH};
pub(crate) mod atoms;
pub(crate) mod containers;
pub(crate) mod data;
pub(crate) mod form;
pub struct RenderResult {
pub html: String,
pub css_head: String,
pub scripts: String,
}
pub(crate) const BUILTIN_TYPES: &[&str] = &[
"Text",
"Button",
"Badge",
"Alert",
"Separator",
"Progress",
"Avatar",
"Image",
"Skeleton",
"Breadcrumb",
"Pagination",
"DescriptionList",
"EmptyState",
"StatCard",
"Checklist",
"Toast",
"NotificationDropdown",
"Sidebar",
"Header",
"DropdownMenu",
"CalendarCell",
"ActionCard",
"ProductTile",
"RawHtml",
"Card",
"Modal",
"Tabs",
"KanbanBoard",
"PageHeader",
"DetailPage",
"Grid",
"Collapsible",
"FormSection",
"ButtonGroup",
"Form",
"Input",
"Select",
"Checkbox",
"Switch",
"CheckboxList",
"CheckboxGroup",
"Table",
"DataTable",
];
pub fn render_spec_to_html(spec: &Spec, data: &Value) -> String {
let body = render_element(&spec.root, spec, data, 1);
let body_or_root_hidden = if body.is_empty() && spec_root_was_hidden(spec, data) {
String::from("<!-- ferro-json-ui: root hidden -->")
} else {
body
};
format!(
"<div class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">{body_or_root_hidden}</div>"
)
}
pub fn render_spec_to_html_with_plugins(spec: &Spec, data: &Value) -> RenderResult {
let html = render_spec_to_html(spec, data);
let plugin_types = collect_plugin_types(spec);
if plugin_types.is_empty() {
return RenderResult {
html,
css_head: String::new(),
scripts: String::new(),
};
}
let type_names: Vec<String> = plugin_types.into_iter().collect();
let assets = collect_plugin_assets(&type_names);
RenderResult {
html,
css_head: render_css_tags(&assets.css),
scripts: render_js_tags(&assets.js, &assets.init_scripts),
}
}
pub(crate) fn render_element(id: &str, spec: &Spec, data: &Value, depth: usize) -> String {
if depth > MAX_NESTING_DEPTH + 1 {
return format!(
"<!-- ferro-json-ui: depth limit exceeded at depth {depth} (max={MAX_NESTING_DEPTH}) — spec should have been rejected at parse time -->"
);
}
let Some(el) = spec.elements.get(id) else {
return format!(
"<!-- ferro-json-ui: element references missing id '{}' -->",
html_escape(id)
);
};
if let Some(vis) = &el.visible {
if !vis.evaluate(data) {
return String::new();
}
}
match el.type_name.as_str() {
"Text" => atoms::render_text(el, spec, data, depth),
"Button" => atoms::render_button(el, spec, data, depth),
"Badge" => atoms::render_badge(el, spec, data, depth),
"Alert" => atoms::render_alert(el, spec, data, depth),
"Separator" => atoms::render_separator(el, spec, data, depth),
"Progress" => atoms::render_progress(el, spec, data, depth),
"Avatar" => atoms::render_avatar(el, spec, data, depth),
"Image" => atoms::render_image(el, spec, data, depth),
"Skeleton" => atoms::render_skeleton(el, spec, data, depth),
"Breadcrumb" => atoms::render_breadcrumb(el, spec, data, depth),
"Pagination" => atoms::render_pagination(el, spec, data, depth),
"DescriptionList" => atoms::render_description_list(el, spec, data, depth),
"EmptyState" => atoms::render_empty_state(el, spec, data, depth),
"StatCard" => atoms::render_stat_card(el, spec, data, depth),
"Checklist" => atoms::render_checklist(el, spec, data, depth),
"Toast" => atoms::render_toast(el, spec, data, depth),
"NotificationDropdown" => atoms::render_notification_dropdown(el, spec, data, depth),
"Sidebar" => atoms::render_sidebar(el, spec, data, depth),
"Header" => atoms::render_header(el, spec, data, depth),
"DropdownMenu" => atoms::render_dropdown_menu(el, spec, data, depth),
"CalendarCell" => atoms::render_calendar_cell(el, spec, data, depth),
"ActionCard" => atoms::render_action_card(el, spec, data, depth),
"ProductTile" => atoms::render_product_tile(el, spec, data, depth),
"RawHtml" => atoms::render_raw_html(el, spec, data, depth),
"Card" => containers::render_card(el, spec, data, depth),
"Modal" => containers::render_modal(el, spec, data, depth),
"Tabs" => containers::render_tabs(el, spec, data, depth),
"KanbanBoard" => containers::render_kanban_board(el, spec, data, depth),
"PageHeader" => containers::render_page_header(el, spec, data, depth),
"DetailPage" => containers::render_detail_page(el, spec, data, depth),
"Grid" => containers::render_grid(el, spec, data, depth),
"Collapsible" => containers::render_collapsible(el, spec, data, depth),
"FormSection" => containers::render_form_section(el, spec, data, depth),
"ButtonGroup" => containers::render_button_group(el, spec, data, depth),
"Form" => form::render_form(el, spec, data, depth),
"Input" => form::render_input(el, spec, data, depth),
"Select" => form::render_select(el, spec, data, depth),
"Checkbox" => form::render_checkbox(el, spec, data, depth),
"Switch" => form::render_switch(el, spec, data, depth),
"CheckboxList" => form::render_checkbox_list(el, spec, data, depth),
"CheckboxGroup" => form::render_checkbox_list(el, spec, data, depth),
"Table" => data::render_table(el, spec, data, depth),
"DataTable" => data::render_data_table(el, spec, data, depth),
other => render_plugin_or_unknown(other, el, data),
}
}
fn render_plugin_or_unknown(type_name: &str, el: &crate::spec::Element, data: &Value) -> String {
match with_plugin(type_name, |p| p.render(&el.props, data)) {
Some(html) => html,
None => format!(
"<!-- ferro-json-ui: unknown component type '{}' -->",
html_escape(type_name)
),
}
}
fn spec_root_was_hidden(spec: &Spec, data: &Value) -> bool {
spec.elements
.get(&spec.root)
.and_then(|el| el.visible.as_ref())
.map(|vis| !vis.evaluate(data))
.unwrap_or(false)
}
pub(crate) fn collect_plugin_types(spec: &Spec) -> HashSet<String> {
let mut types = HashSet::new();
for el in spec.elements.values() {
if !BUILTIN_TYPES.contains(&el.type_name.as_str()) {
types.insert(el.type_name.clone());
}
}
types
}
pub(crate) fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
pub(crate) fn render_css_tags(assets: &[Asset]) -> String {
let mut out = String::new();
for asset in assets {
out.push_str("<link rel=\"stylesheet\" href=\"");
out.push_str(&html_escape(&asset.url));
out.push('"');
if let Some(integrity) = &asset.integrity {
out.push_str(" integrity=\"");
out.push_str(&html_escape(integrity));
out.push('"');
}
if let Some(co) = &asset.crossorigin {
out.push_str(" crossorigin=\"");
out.push_str(&html_escape(co));
out.push('"');
}
out.push_str(">\n");
}
out
}
pub(crate) fn render_js_tags(assets: &[Asset], init_scripts: &[String]) -> String {
let mut out = String::new();
for asset in assets {
out.push_str("<script src=\"");
out.push_str(&html_escape(&asset.url));
out.push('"');
if let Some(integrity) = &asset.integrity {
out.push_str(" integrity=\"");
out.push_str(&html_escape(integrity));
out.push('"');
}
if let Some(co) = &asset.crossorigin {
out.push_str(" crossorigin=\"");
out.push_str(&html_escape(co));
out.push('"');
}
out.push_str("></script>\n");
}
for init in init_scripts {
out.push_str("<script>");
out.push_str(init);
out.push_str("</script>\n");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plugin::{register_plugin, Asset, JsonUiPlugin};
use crate::spec::{Element, Spec};
use crate::visibility::{Visibility, VisibilityCondition, VisibilityOperator};
use serde_json::json;
fn mk_element(type_name: &str) -> Element {
Element {
type_name: type_name.to_string(),
props: Value::Null,
children: Vec::new(),
action: None,
visible: None,
each: None,
if_: None,
}
}
fn build_spec_unchecked(root: &str, elements: Vec<(&str, Element)>) -> Spec {
let mut spec = Spec::builder()
.element("__tmp__", Element::new("Text"))
.build()
.expect("builder accepts trivial well-formed spec");
spec.root = root.to_string();
spec.elements.clear();
for (id, el) in elements {
spec.elements.insert(id.to_string(), el);
}
spec
}
#[test]
fn walker_unknown_type_emits_diagnostic() {
let spec = build_spec_unchecked("root", vec![("root", mk_element("ImaginaryWidget"))]);
let html = render_spec_to_html(&spec, &json!({}));
assert!(
html.contains("<!-- ferro-json-ui: unknown component type 'ImaginaryWidget' -->"),
"got: {html}"
);
}
#[test]
fn walker_missing_child_emits_diagnostic() {
let mut spec = Spec::builder()
.element("real", Element::new("Text"))
.build()
.expect("ok");
spec.root = "ghost".to_string();
let html = render_spec_to_html(&spec, &json!({}));
assert!(
html.contains("<!-- ferro-json-ui: element references missing id 'ghost' -->"),
"got: {html}"
);
}
#[test]
fn walker_root_hidden_emits_root_hidden_comment() {
let mut spec = Spec::builder()
.element("root", Element::new("Text"))
.build()
.expect("ok");
let el = spec.elements.get_mut("root").unwrap();
el.visible = Some(Visibility::Condition(VisibilityCondition {
path: "/show".into(),
operator: VisibilityOperator::Eq,
value: Some(json!(true)),
}));
let html = render_spec_to_html(&spec, &json!({"show": false}));
assert!(
html.contains("<!-- ferro-json-ui: root hidden -->"),
"got: {html}"
);
}
#[test]
fn walker_depth_tripwire_relative() {
let spec = build_spec_unchecked("A", vec![("A", mk_element("Text"))]);
let html = render_element("A", &spec, &json!({}), MAX_NESTING_DEPTH + 2);
assert!(html.contains("depth limit exceeded"), "got: {html}");
}
#[test]
fn walker_depth_tripwire() {
let spec = build_spec_unchecked("A", vec![("A", mk_element("Text"))]);
let html = render_element("A", &spec, &json!({}), MAX_NESTING_DEPTH + 2);
assert!(
html.contains("depth limit exceeded"),
"expected 'depth limit exceeded' in: {html}"
);
assert!(html.contains("max=16"), "expected 'max=16' in: {html}");
assert!(
!html.contains("cycle"),
"depth tripwire must not mention 'cycle'; got: {html}"
);
}
#[test]
fn walker_plugin_dispatch_invokes_with_plugin() {
struct TestPlugin;
impl JsonUiPlugin for TestPlugin {
fn component_type(&self) -> &str {
"FerroPhase116PluginDispatchTest"
}
fn props_schema(&self) -> serde_json::Value {
serde_json::json!({})
}
fn render(&self, _props: &Value, _data: &Value) -> String {
"<div data-test-plugin>X</div>".to_string()
}
fn css_assets(&self) -> Vec<Asset> {
Vec::new()
}
fn js_assets(&self) -> Vec<Asset> {
Vec::new()
}
fn init_script(&self) -> Option<String> {
None
}
}
register_plugin(TestPlugin);
let spec = build_spec_unchecked(
"root",
vec![("root", mk_element("FerroPhase116PluginDispatchTest"))],
);
let html = render_spec_to_html(&spec, &json!({}));
assert!(
html.contains("<div data-test-plugin>X</div>"),
"got: {html}"
);
}
#[test]
fn walker_plugin_asset_collection_returns_plugin_types() {
struct TestPluginB;
impl JsonUiPlugin for TestPluginB {
fn component_type(&self) -> &str {
"FerroPhase116AssetCollectTestPlugin"
}
fn props_schema(&self) -> serde_json::Value {
serde_json::json!({})
}
fn render(&self, _props: &Value, _data: &Value) -> String {
String::new()
}
fn css_assets(&self) -> Vec<Asset> {
Vec::new()
}
fn js_assets(&self) -> Vec<Asset> {
Vec::new()
}
fn init_script(&self) -> Option<String> {
None
}
}
register_plugin(TestPluginB);
let spec = build_spec_unchecked(
"root",
vec![
("root", mk_element("Text")),
("plug", mk_element("FerroPhase116AssetCollectTestPlugin")),
],
);
let types = collect_plugin_types(&spec);
assert!(types.contains("FerroPhase116AssetCollectTestPlugin"));
assert!(!types.contains("Text"));
}
#[test]
fn walker_plugins_cannot_shadow_builtins() {
struct CardShadow;
impl JsonUiPlugin for CardShadow {
fn component_type(&self) -> &str {
"Card"
}
fn props_schema(&self) -> serde_json::Value {
serde_json::json!({})
}
fn render(&self, _props: &Value, _data: &Value) -> String {
"<div data-from-plugin>SHADOW</div>".to_string()
}
fn css_assets(&self) -> Vec<Asset> {
Vec::new()
}
fn js_assets(&self) -> Vec<Asset> {
Vec::new()
}
fn init_script(&self) -> Option<String> {
None
}
}
register_plugin(CardShadow);
let spec = build_spec_unchecked("root", vec![("root", mk_element("Card"))]);
let html = render_spec_to_html(&spec, &json!({}));
assert!(
!html.contains("data-from-plugin"),
"plugin must not shadow built-in Card; got: {html}"
);
}
#[test]
fn top_level_wrapper_present() {
let spec = build_spec_unchecked("root", vec![("root", mk_element("Text"))]);
let html = render_spec_to_html(&spec, &json!({}));
assert!(
html.starts_with("<div class=\"flex flex-wrap gap-4"),
"got: {html}"
);
assert!(html.ends_with("</div>"), "got: {html}");
}
#[test]
fn html_escape_basic() {
assert_eq!(html_escape("<script>"), "<script>");
assert_eq!(html_escape("a&b"), "a&b");
assert_eq!(html_escape("\"quoted\""), ""quoted"");
}
#[test]
fn builtin_types_count_matches_dispatch() {
assert_eq!(BUILTIN_TYPES.len(), 43);
}
}