736 lines
26 KiB
Rust
736 lines
26 KiB
Rust
//! 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 <target>`
|
||
//! 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<ThemeSignature, ThemedBlock> = 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::<Vec<_>>()
|
||
.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<CornerRadius>| 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<String> {
|
||
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::<String>(),
|
||
}),
|
||
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<String> {
|
||
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<PenFill>> {
|
||
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::Line(_) | PenNode::Image(_) | PenNode::Ref(_) => None,
|
||
}
|
||
}
|
||
|
||
/// First `Solid` fill's color string in a fill list.
|
||
fn first_solid_color(fills: &[PenFill]) -> Option<String> {
|
||
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::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<u8> {
|
||
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<f32> { 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::{EllipseNode, PenNodeBase, RectangleNode, TextContent, 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<PenNode>) -> 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<String, String> {
|
||
let mut m = BTreeMap::new();
|
||
m.insert(name.to_string(), value.to_string());
|
||
m
|
||
}
|
||
|
||
fn solid(color: &str) -> Vec<PenFill> {
|
||
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("<!DOCTYPE html>"));
|
||
assert!(html.contains("<body>"));
|
||
assert!(html.contains("</body>"));
|
||
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 & <world>")]);
|
||
let html = Html.generate(&doc);
|
||
assert!(html.contains("<span"));
|
||
assert!(html.contains("Hello & <world>"));
|
||
}
|
||
|
||
#[test]
|
||
fn vue_emits_template_script_style_blocks() {
|
||
let doc = empty_doc();
|
||
let s = Vue.generate(&doc);
|
||
assert!(s.contains("<template>"));
|
||
assert!(s.contains("</template>"));
|
||
assert!(s.contains("<script setup"));
|
||
assert!(s.contains("<style scoped>"));
|
||
}
|
||
|
||
#[test]
|
||
fn svelte_emits_script_then_markup_then_style() {
|
||
let doc = empty_doc();
|
||
let s = Svelte.generate(&doc);
|
||
let script_pos = s.find("<script").unwrap();
|
||
let style_pos = s.find("<style>").unwrap();
|
||
// Script before style — Svelte SFC convention.
|
||
assert!(script_pos < style_pos);
|
||
}
|
||
|
||
#[test]
|
||
fn vue_includes_variable_css_in_style_block() {
|
||
let mut doc = empty_doc();
|
||
let mut vars = BTreeMap::new();
|
||
vars.insert("primary".to_string(), color_def("#abc"));
|
||
doc.variables = Some(vars);
|
||
let s = Vue.generate(&doc);
|
||
assert!(s.contains("--primary: #abc;"));
|
||
}
|
||
|
||
#[test]
|
||
fn react_emits_functional_component_with_jsx_fragment() {
|
||
let doc = empty_doc();
|
||
let s = React.generate(&doc);
|
||
assert!(s.contains("import React from 'react';"));
|
||
assert!(s.contains("export default function Page()"));
|
||
assert!(s.contains("<>"));
|
||
assert!(s.contains("</>"));
|
||
}
|
||
|
||
#[test]
|
||
fn flutter_emits_stack_with_positioned_children() {
|
||
let doc = doc_with_nodes(vec![rect("n10", 5.0, 10.0, 100.0, 50.0, Some("#ff0000"))]);
|
||
let s = Flutter.generate(&doc);
|
||
assert!(s.contains("import 'package:flutter/material.dart';"));
|
||
assert!(s.contains("Stack(children:"));
|
||
assert!(s.contains("Positioned(left: 5, top: 10"));
|
||
assert!(s.contains("Container(width: 100, height: 50"));
|
||
assert!(s.contains("Color.fromARGB(255,255,0,0)"));
|
||
}
|
||
|
||
#[test]
|
||
fn swiftui_emits_zstack_with_positioned_views() {
|
||
let doc = doc_with_nodes(vec![rect("n10", 0.0, 0.0, 100.0, 50.0, None)]);
|
||
let s = SwiftUi.generate(&doc);
|
||
assert!(s.contains("import SwiftUI"));
|
||
assert!(s.contains("ZStack {"));
|
||
assert!(s.contains("Rectangle()"));
|
||
assert!(s.contains(".frame(width: 100, height: 50)"));
|
||
}
|
||
|
||
#[test]
|
||
fn swiftui_emits_ellipse_for_ellipse_kind() {
|
||
let doc = doc_with_nodes(vec![ellipse("n10", 0.0, 0.0, 20.0, 20.0)]);
|
||
let s = SwiftUi.generate(&doc);
|
||
assert!(s.contains("Ellipse()"));
|
||
}
|
||
|
||
#[test]
|
||
fn compose_emits_composable_box_with_offset_size() {
|
||
let doc = doc_with_nodes(vec![rect("n10", 5.0, 10.0, 100.0, 50.0, Some("#ff0000"))]);
|
||
let s = Compose.generate(&doc);
|
||
assert!(s.contains("@Composable"));
|
||
assert!(s.contains("Modifier.offset(x = 5.dp, y = 10.dp)"));
|
||
assert!(s.contains("size(width = 100.dp, height = 50.dp)"));
|
||
assert!(s.contains("Color(255, 0, 0, 255)"));
|
||
}
|
||
|
||
#[test]
|
||
fn react_native_emits_view_tree_with_absolute_positions() {
|
||
let doc = doc_with_nodes(vec![rect("n10", 0.0, 0.0, 80.0, 40.0, None)]);
|
||
let s = ReactNative.generate(&doc);
|
||
assert!(s.contains("import { View, Text } from 'react-native';"));
|
||
assert!(s.contains("export default function Page()"));
|
||
assert!(s.contains("<View style={{"));
|
||
assert!(s.contains("position: 'absolute'"));
|
||
assert!(s.contains("width: 80, height: 40"));
|
||
}
|
||
|
||
#[test]
|
||
fn html_skips_hidden_nodes() {
|
||
let mut hidden = rect("n10", 0.0, 0.0, 10.0, 10.0, None);
|
||
hidden.base_mut().visible = Some(false);
|
||
hidden.base_mut().name = Some("hidden".into());
|
||
let doc = doc_with_nodes(vec![hidden]);
|
||
let html = Html.generate(&doc);
|
||
assert!(!html.contains("hidden"));
|
||
}
|
||
|
||
#[test]
|
||
fn css_variables_empty_doc_emits_header_only() {
|
||
let doc = empty_doc();
|
||
let css = CssVariables.generate(&doc);
|
||
assert!(css.contains("Generated by OpenPencil"));
|
||
assert!(!css.contains(":root"));
|
||
}
|
||
|
||
#[test]
|
||
fn falls_back_to_bare_children_when_unpaged() {
|
||
let mut doc = empty_doc();
|
||
doc.children = vec![rect("n10", 1.0, 2.0, 30.0, 40.0, None)];
|
||
let html = Html.generate(&doc);
|
||
assert!(html.contains("left:1px"));
|
||
assert!(html.contains("width:30px"));
|
||
}
|
||
}
|