//! Code generation — Rust ports of `packages/pen-codegen/src/`. //! v1 ships the simplest of the 9 TS generators: `css-variables`, //! which emits the design's variable table as CSS custom properties. //! Future generators (React + Tailwind, HTML, Vue, Svelte, Flutter, //! SwiftUI, Compose, React Native) live behind the same `Codegen` //! trait so a single dispatcher (eventually a `op codegen ` //! CLI) can fan out by target enum. //! //! All generators consume the canonical `jian_ops_schema::PenDocument` //! and walk `PenNode` via `op_editor_core::PenNodeExt`. The spine here //! holds the `Codegen` trait, the `CssVariables` generator, shared //! node-accessor helpers, and the test suite; per-framework markup //! generators live in the sibling `codegen_targets` module. /// AI-driven code-generation pipeline (planning → chunk → assembly). /// Gated behind the `ai` feature so the deterministic generators stay /// dependency-light. #[cfg(feature = "ai")] pub mod ai; mod codegen_targets; pub use crate::codegen_targets::{ Compose, Flutter, Html, React, ReactNative, Svelte, SwiftUi, Vue, }; use jian_ops_schema::node::PenNode; use jian_ops_schema::style::PenFill; use jian_ops_schema::variable::{VariableScalar, VariableValue}; use jian_ops_schema::PenDocument; use op_editor_core::pen_node_ext::PenNodeExt; /// One generator. Implementations are pure (read `PenDocument`, /// return `String`); the CLI / panel layer owns file-write side /// effects. pub trait Codegen { fn target_label(&self) -> &'static str; fn generate(&self, doc: &PenDocument) -> String; } /// CSS custom properties generator. Walks `doc.variables` and emits /// `:root { --name: value; }`. Color variables stay as their hex /// string; number/bool/string variables stringify their scalar. /// Themed values emit ONE entry per theme combination using a /// `:root[data-axis="value"]` selector so the same CSS reflows /// across theme switches with no JS. pub struct CssVariables; impl Codegen for CssVariables { fn target_label(&self) -> &'static str { "css-variables" } fn generate(&self, doc: &PenDocument) -> String { let mut out = String::new(); out.push_str("/* Generated by OpenPencil — codegen::CssVariables */\n"); let Some(variables) = doc.variables.as_ref() else { return out; }; // Pass 1: scalar (theme-agnostic) variables go under :root. let mut root_scalars: Vec<(String, String)> = Vec::new(); for (name, def) in variables { if let VariableValue::Scalar(s) = &def.value { root_scalars.push((name.clone(), scalar_to_css(s))); } } if !root_scalars.is_empty() { out.push_str(":root {\n"); for (name, value) in &root_scalars { out.push_str(&format!(" --{}: {};\n", css_ident(name), value)); } out.push_str("}\n"); } // Pass 2: themed variables → one block per (axis, value) combo. // Group entries by their theme map signature so a "mode=dark" // entry from variable A and another from B share one block. use std::collections::BTreeMap; // Maps a theme-map signature (sorted axis/value pairs) to the // (variable-name, css-value) entries that share that signature. type ThemeSignature = Vec<(String, String)>; type ThemedBlock = Vec<(String, String)>; let mut themed: BTreeMap = BTreeMap::new(); for (name, def) in variables { if let VariableValue::Themed(entries) = &def.value { for e in entries { let key: Vec<(String, String)> = e .theme .as_ref() .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) .unwrap_or_default(); themed .entry(key) .or_default() .push((name.clone(), scalar_to_css(&e.value))); } } } for (axes, decls) in &themed { if axes.is_empty() { out.push_str(":root {\n"); } else { let sel = axes .iter() .map(|(k, v)| format!("[data-{}=\"{}\"]", k, v)) .collect::>() .join(""); out.push_str(&format!(":root{} {{\n", sel)); } for (name, value) in decls { out.push_str(&format!(" --{}: {};\n", css_ident(name), value)); } out.push_str("}\n"); } out } } // --- Shared node-accessor helpers (canonical-schema flavour) ------- // // shell-core's flat `Node` exposed `bounds` / `fill` / `stroke` / // `corner_radius` / `text` / `hidden` directly. The canonical // `PenNode` is a 12-variant enum: these helpers give the markup // generators the same flat view through `PenNodeExt`. /// Top-level node forest the generators walk: the first page's /// children when the document is paged, otherwise the bare /// `children` fallback. pub(crate) fn root_nodes(doc: &PenDocument) -> &[PenNode] { match doc.pages.as_ref().and_then(|p| p.first()) { Some(page) => &page.children, None => &doc.children, } } /// True when the node is explicitly hidden (`visible == false`). /// A missing `visible` flag means shown. pub(crate) fn node_hidden(node: &PenNode) -> bool { node.base().visible == Some(false) } /// Document-space origin of the node (`base.x` / `base.y`, 0 default). pub(crate) fn node_origin(node: &PenNode) -> (f64, f64) { let base = node.base(); (base.x.unwrap_or(0.0), base.y.unwrap_or(0.0)) } /// Literal pixel size of the node. Keyword / expression sizing is not /// a concrete number, so it reads as 0 — same degrade the TS /// generators apply for `fit_content` / `$var` widths. pub(crate) fn node_size(node: &PenNode) -> (f64, f64) { match node { PenNode::Line(n) => { // A line has no width/height field — derive its box from // the (x,y)→(x2,y2) span so absolute layout still works. let base = node.base(); let x1 = base.x.unwrap_or(0.0); let y1 = base.y.unwrap_or(0.0); let x2 = n.x2.unwrap_or(x1); let y2 = n.y2.unwrap_or(y1); ((x2 - x1).abs(), (y2 - y1).abs()) } _ => ( node.width_px().unwrap_or(0.0), node.height_px().unwrap_or(0.0), ), } } /// Node rotation in radians (`base.rotation`, 0 default). The /// canonical schema stores rotation in degrees; the editor / TS /// renderer treat that field as degrees too, so emit it verbatim and /// let the per-target helper pick `deg` vs `rad`. pub(crate) fn node_rotation_deg(node: &PenNode) -> f64 { node.base().rotation.unwrap_or(0.0) } /// Corner radius in document px. Uniform `CornerRadius` reads as-is; /// per-corner radii collapse to their first value (CSS shorthand is /// a follow-up). Variants without a corner-radius field read 0. pub(crate) fn node_corner_radius(node: &PenNode) -> f64 { use jian_ops_schema::node::CornerRadius; let uniform = |c: &Option| match c { Some(CornerRadius::Uniform(r)) => *r, Some(CornerRadius::PerCorner(arr)) => arr.first().copied().unwrap_or(0.0), None => 0.0, }; match node { PenNode::Frame(n) => uniform(&n.container.corner_radius), PenNode::Group(n) => uniform(&n.container.corner_radius), PenNode::Rectangle(n) => uniform(&n.container.corner_radius), PenNode::Ellipse(n) => n.corner_radius.unwrap_or(0.0), PenNode::Polygon(n) => n.corner_radius.unwrap_or(0.0), _ => 0.0, } } /// Children of the node (empty slice for leaf variants). pub(crate) fn node_children(node: &PenNode) -> &[PenNode] { node.children().map(|v| v.as_slice()).unwrap_or(&[]) } /// True when the node renders as text (`Text` variant). pub(crate) fn node_is_text(node: &PenNode) -> bool { matches!(node, PenNode::Text(_)) } /// True when the node renders as an ellipse. pub(crate) fn node_is_ellipse(node: &PenNode) -> bool { matches!(node, PenNode::Ellipse(_)) } /// Plain-text body of a node. `Text` variants flatten their /// `TextContent` (joining styled segments); every other variant has /// no inline body. pub(crate) fn node_text(node: &PenNode) -> Option { use jian_ops_schema::node::TextContent; match node { PenNode::Text(n) => Some(match &n.content { TextContent::Plain(s) => s.clone(), TextContent::Styled(segs) => segs.iter().map(|s| s.text.as_str()).collect::(), }), PenNode::TextInput(n) => n.value.clone().or_else(|| n.placeholder.clone()), _ => None, } } /// First solid-fill color of a node as a `#hex` / `rgba()` string, /// when the node carries one. Gradient / image fills are not a flat /// color — they read as `None` (same degrade as the TS generators). pub(crate) fn node_fill_css(node: &PenNode) -> Option { let fills = node_fills(node)?; first_solid_color(fills) } /// Borrow the node's fill list, when its variant carries fills. fn node_fills(node: &PenNode) -> Option<&Vec> { match node { PenNode::Frame(n) => n.container.fill.as_ref(), PenNode::Group(n) => n.container.fill.as_ref(), PenNode::Rectangle(n) => n.container.fill.as_ref(), PenNode::Ellipse(n) => n.fill.as_ref(), PenNode::Polygon(n) => n.fill.as_ref(), PenNode::Path(n) => n.fill.as_ref(), PenNode::Text(n) => n.fill.as_ref(), PenNode::TextInput(n) => n.fill.as_ref(), PenNode::IconFont(n) => n.fill.as_ref(), PenNode::TextArea(n) => n.fill.as_ref(), PenNode::Select(n) => n.fill.as_ref(), PenNode::Switch(n) => n.fill.as_ref(), PenNode::Checkbox(n) => n.fill.as_ref(), PenNode::Slider(n) => n.fill.as_ref(), PenNode::RadioGroup(n) => n.fill.as_ref(), PenNode::NumberInput(n) => n.fill.as_ref(), PenNode::Progress(n) => n.fill.as_ref(), PenNode::Tabs(n) => n.fill.as_ref(), PenNode::Line(_) | PenNode::Image(_) | PenNode::Ref(_) => None, } } /// First `Solid` fill's color string in a fill list. fn first_solid_color(fills: &[PenFill]) -> Option { fills.iter().find_map(|f| match f { PenFill::Solid(body) => Some(body.color.clone()), _ => None, }) } /// Stroke description of a node: `(color, width)`. Present only when /// the variant carries a `PenStroke` with a solid fill and a uniform /// thickness. Per-side thickness collapses to its first value. pub(crate) fn node_stroke_css(node: &PenNode) -> Option<(String, f32)> { use jian_ops_schema::style::StrokeThickness; let stroke = match node { PenNode::Frame(n) => n.container.stroke.as_ref(), PenNode::Group(n) => n.container.stroke.as_ref(), PenNode::Rectangle(n) => n.container.stroke.as_ref(), PenNode::Ellipse(n) => n.stroke.as_ref(), PenNode::Polygon(n) => n.stroke.as_ref(), PenNode::Path(n) => n.stroke.as_ref(), PenNode::Line(n) => n.stroke.as_ref(), PenNode::TextInput(n) => n.stroke.as_ref(), PenNode::IconFont(n) => n.stroke.as_ref(), PenNode::TextArea(n) => n.stroke.as_ref(), PenNode::Select(n) => n.stroke.as_ref(), PenNode::Switch(n) => n.stroke.as_ref(), PenNode::Checkbox(n) => n.stroke.as_ref(), PenNode::Slider(n) => n.stroke.as_ref(), PenNode::RadioGroup(n) => n.stroke.as_ref(), PenNode::NumberInput(n) => n.stroke.as_ref(), PenNode::Progress(n) => n.stroke.as_ref(), PenNode::Tabs(n) => n.stroke.as_ref(), PenNode::Text(_) | PenNode::Image(_) | PenNode::Ref(_) => None, }?; let width = match &stroke.thickness { StrokeThickness::Uniform(w) => *w, StrokeThickness::PerSide(arr) => arr.first().copied().unwrap_or(0.0), StrokeThickness::Sided(s) => s.top.or(s.right).or(s.bottom).or(s.left).unwrap_or(0.0), }; let color = stroke .fill .as_ref() .and_then(|fills| first_solid_color(fills)) .unwrap_or_else(|| "#000000".to_string()); Some((color, width)) } // --- CSS scalar / colour helpers ----------------------------------- /// Parse a `#rgb` / `#rrggbb` / `#rrggbbaa` / `rgba(...)` colour /// string into normalized `(r, g, b, a)` floats in `0.0..=1.0`. /// Returns `None` for anything not understood — callers fall back to /// emitting the raw string verbatim, which is valid CSS for named /// colours or `var(--ref)`. pub(crate) fn parse_color(s: &str) -> Option<(f32, f32, f32, f32)> { let t = s.trim(); if let Some(hex) = t.strip_prefix('#') { let h = hex.as_bytes(); let nyb = |c: u8| -> Option { match c { b'0'..=b'9' => Some(c - b'0'), b'a'..=b'f' => Some(c - b'a' + 10), b'A'..=b'F' => Some(c - b'A' + 10), _ => None, } }; let pair = |hi: u8, lo: u8| -> Option { Some((nyb(hi)? * 16 + nyb(lo)?) as f32 / 255.0) }; return match h.len() { 3 => Some((pair(h[0], h[0])?, pair(h[1], h[1])?, pair(h[2], h[2])?, 1.0)), 6 => Some((pair(h[0], h[1])?, pair(h[2], h[3])?, pair(h[4], h[5])?, 1.0)), 8 => Some(( pair(h[0], h[1])?, pair(h[2], h[3])?, pair(h[4], h[5])?, pair(h[6], h[7])?, )), _ => None, }; } None } /// Normalize a colour string for emission as a CSS value. Hex / /// rgba inputs round-trip through `parse_color`; anything else /// (named colour, `var(--ref)`) passes through verbatim. pub(crate) fn color_to_css(s: &str) -> String { match parse_color(s) { Some((r, g, b, a)) => { let to_u8 = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round() as u8; if a >= 0.999 { format!("rgb({},{},{})", to_u8(r), to_u8(g), to_u8(b)) } else { format!("rgba({},{},{},{:.3})", to_u8(r), to_u8(g), to_u8(b), a) } } None => s.to_string(), } } pub(crate) fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) } fn scalar_to_css(s: &VariableScalar) -> String { match s { VariableScalar::Bool(b) => b.to_string(), VariableScalar::Num(n) => fmt_num(*n), VariableScalar::Str(s) => s.clone(), } } /// Format an `f64` the way the TS generators print numbers — drop a /// trailing `.0` so `12.0` renders as `12`. pub(crate) fn fmt_num(n: f64) -> String { if n.fract() == 0.0 && n.is_finite() { format!("{}", n as i64) } else { format!("{}", n) } } fn css_ident(name: &str) -> String { name.chars() .map(|c| { if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' } }) .collect() } #[cfg(test)] mod tests { use super::*; use jian_ops_schema::node::{ BoolOrExpression, CheckboxNode, EllipseNode, NumberInputNode, NumberOrExpression, PenNodeBase, ProgressNode, RadioGroupNode, RectangleNode, SelectNode, SelectOption, SliderNode, SwitchNode, TabsNode, TextAreaNode, TextContent, TextInputNode, TextNode, }; use jian_ops_schema::page::PenPage; use jian_ops_schema::sizing::SizingBehavior; use jian_ops_schema::style::{PenFill, SolidFillBody}; use jian_ops_schema::variable::{ ThemedValue, VariableDefinition, VariableKind, VariableScalar, VariableValue, }; use std::collections::BTreeMap; /// Bare empty document — no variables, no pages, no children. fn empty_doc() -> PenDocument { PenDocument { version: "0.8.0".into(), name: None, themes: None, variables: None, pages: None, children: Vec::new(), format_version: None, id: None, app: None, routes: None, state: None, lifecycle: None, logic_modules: None, design_md: None, } } /// Replace the document's node forest with a single page holding /// `nodes`, mirroring the old `page.children` test helper. fn doc_with_nodes(nodes: Vec) -> PenDocument { let mut doc = empty_doc(); doc.pages = Some(vec![PenPage { id: "p1".into(), name: "Page 1".into(), children: nodes, state: None, lifecycle: None, }]); doc } fn axis(name: &str, value: &str) -> BTreeMap { let mut m = BTreeMap::new(); m.insert(name.to_string(), value.to_string()); m } fn solid(color: &str) -> Vec { vec![PenFill::Solid(SolidFillBody { color: color.into(), explain: None, opacity: None, blend_mode: None, })] } /// A rectangle at `(x,y)` sized `w×h` with an optional fill. fn rect(id: &str, x: f64, y: f64, w: f64, h: f64, fill: Option<&str>) -> PenNode { let container = jian_ops_schema::node::ContainerProps { width: Some(SizingBehavior::Number(w)), height: Some(SizingBehavior::Number(h)), fill: fill.map(solid), ..Default::default() }; PenNode::Rectangle(RectangleNode { base: PenNodeBase { id: id.into(), x: Some(x), y: Some(y), ..Default::default() }, container, children: None, state: None, bindings: None, events: None, lifecycle: None, semantics: None, gestures: None, route: None, }) } fn ellipse(id: &str, x: f64, y: f64, w: f64, h: f64) -> PenNode { PenNode::Ellipse(EllipseNode { base: PenNodeBase { id: id.into(), x: Some(x), y: Some(y), ..Default::default() }, width: Some(SizingBehavior::Number(w)), height: Some(SizingBehavior::Number(h)), corner_radius: None, inner_radius: None, start_angle: None, sweep_angle: None, fill: None, stroke: None, effects: None, state: None, bindings: None, events: None, lifecycle: None, semantics: None, gestures: None, route: None, }) } fn text(id: &str, x: f64, y: f64, w: f64, h: f64, body: &str) -> PenNode { PenNode::Text(TextNode { base: PenNodeBase { id: id.into(), x: Some(x), y: Some(y), ..Default::default() }, width: Some(SizingBehavior::Number(w)), height: Some(SizingBehavior::Number(h)), content: TextContent::Plain(body.into()), font_family: None, font_size: None, font_weight: None, font_style: None, letter_spacing: None, line_height: None, text_align: None, text_align_vertical: None, text_growth: None, underline: None, strikethrough: None, fill: None, effects: None, state: None, bindings: None, events: None, lifecycle: None, semantics: None, gestures: None, route: None, }) } fn color_def(value: &str) -> VariableDefinition { VariableDefinition { kind: VariableKind::Color, value: VariableValue::Scalar(VariableScalar::Str(value.into())), } } #[test] fn css_variables_emits_root_scalars() { let mut doc = empty_doc(); let mut vars = BTreeMap::new(); vars.insert("primary".to_string(), color_def("#0066ff")); vars.insert( "spacing".to_string(), VariableDefinition { kind: VariableKind::Number, value: VariableValue::Scalar(VariableScalar::Num(12.0)), }, ); doc.variables = Some(vars); let css = CssVariables.generate(&doc); assert!(css.contains(":root {")); assert!(css.contains("--primary: #0066ff;")); assert!(css.contains("--spacing: 12;")); } #[test] fn css_variables_emits_per_theme_block() { let mut doc = empty_doc(); let mut vars = BTreeMap::new(); vars.insert( "bg".to_string(), VariableDefinition { kind: VariableKind::Color, value: VariableValue::Themed(vec![ ThemedValue { value: VariableScalar::Str("#ffffff".into()), theme: Some(axis("mode", "light")), }, ThemedValue { value: VariableScalar::Str("#000000".into()), theme: Some(axis("mode", "dark")), }, ]), }, ); doc.variables = Some(vars); let css = CssVariables.generate(&doc); assert!(css.contains(":root[data-mode=\"light\"]")); assert!(css.contains(":root[data-mode=\"dark\"]")); assert!(css.contains("--bg: #ffffff;")); assert!(css.contains("--bg: #000000;")); } #[test] fn css_variables_sanitises_non_ident_chars() { let mut doc = empty_doc(); let mut vars = BTreeMap::new(); vars.insert("primary.color".to_string(), color_def("#f00")); doc.variables = Some(vars); let css = CssVariables.generate(&doc); // Dot replaced with dash so the result is a valid CSS ident. assert!(css.contains("--primary-color: #f00;")); } #[test] fn html_emits_doctype_and_body() { let doc = empty_doc(); let html = Html.generate(&doc); assert!(html.contains("")); assert!(html.contains("")); assert!(html.contains("")); assert!(html.contains("OpenPencil")); } #[test] fn html_emits_div_per_node_with_position_and_fill() { let doc = doc_with_nodes(vec![rect("n10", 20.0, 30.0, 100.0, 50.0, Some("#ff0000"))]); let html = Html.generate(&doc); assert!(html.contains("left:20px")); assert!(html.contains("top:30px")); assert!(html.contains("width:100px")); assert!(html.contains("rgb(255,0,0)")); } #[test] fn html_emits_span_for_text_and_escapes_body() { let doc = doc_with_nodes(vec![text("n10", 0.0, 0.0, 100.0, 24.0, "Hello & ")]); let html = Html.generate(&doc); assert!(html.contains("")); assert!(s.contains("")); assert!(s.contains("