//! Code for a layer, as shown by the Inspect panel and returned by `inspect.code`: CSS for the //! layer itself, HTML or React for its subtree, and SwiftUI or Jetpack Compose (stacks for auto //! layout, absolute offsets otherwise). Components become components whose properties are //! parameters and instances become calls passing their values (see [`crate::component_codegen`]); //! variables bound to a layer become CSS custom properties. The output is a starting point, not a //! pixel-exact translation. use figcraft_color::{Color, PaintKind}; use figcraft_doc::*; use serde_json::json; use crate::cmd::CommandSpec; use crate::component_codegen::{Component, Param}; use crate::params::*; use crate::{bad, spec}; pub fn specs() -> Vec { vec![spec!( "inspect.code", "Code for layer", "{id?, language?: css|html|react|leptos|tailwind|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", false, |s, p| { let cmd = "inspect.code"; let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?; let lang = match string(p, "language") { Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|swiftui|compose"))?, None => CodeLang::Css, }; let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() { None | Some("px") => None, Some("rem") => Some(num_or(p, "rootFontSize", 16.0)).filter(|r| r.is_finite() && *r > 0.0 && *r <= 1000.0), Some(_) => return Err(bad(cmd, "unit: px|rem")), }; if string(p, "unit").is_some_and(|u| u.eq_ignore_ascii_case("rem")) && rem.is_none() { return Err(bad(cmd, "rootFontSize: a positive number of pixels (up to 1000)")); } let doc = s.doc()?; if !doc.contains(id) { return Err(bad(cmd, "no such layer")); } let connected = crate::code_connect::link(doc, id, lang).is_some(); let mobile = match get(p, "mobile") { None | Some(serde_json::Value::Null) => None, Some(v) => Some(id_of(v).filter(|m| doc.contains(*m)).ok_or_else(|| bad(cmd, "mobile: the id of a mobile frame"))?), }; let code = match (lang, mobile) { (CodeLang::Tailwind, Some(m)) => crate::tailwind::code(doc, id, Some(m)), _ => code_for(doc, id, lang), }; let code = match rem { Some(root) if lang.is_web() => px_to_rem(&code, root), _ => code, }; Ok(json!({"code": code, "language": lang.id(), "connected": connected})) } )] } /// Languages the Inspect panel offers. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum CodeLang { #[default] Css, /// The whole layer tree as nested HTML elements with a stylesheet. Html, /// A React function component (TSX) with its stylesheet. React, /// A Leptos component (`view!` markup) with its stylesheet. Leptos, /// A React function component styled with Tailwind CSS v4 utilities (no stylesheet). Tailwind, SwiftUi, Compose, } impl CodeLang { pub const ALL: [CodeLang; 7] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::Tailwind, CodeLang::SwiftUi, CodeLang::Compose]; pub fn id(self) -> &'static str { match self { CodeLang::Css => "css", CodeLang::Html => "html", CodeLang::React => "react", CodeLang::Leptos => "leptos", CodeLang::Tailwind => "tailwind", CodeLang::SwiftUi => "swiftui", CodeLang::Compose => "compose", } } pub fn label(self) -> &'static str { match self { CodeLang::Css => "CSS", CodeLang::Html => "HTML + CSS", CodeLang::React => "React", CodeLang::Leptos => "Leptos", CodeLang::Tailwind => "React + Tailwind", CodeLang::SwiftUi => "SwiftUI", CodeLang::Compose => "Compose", } } /// Languages whose lengths are CSS pixels that can be shown in rem (not Tailwind: its lengths /// are utility classes). pub fn is_web(self) -> bool { matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos) } pub fn from_id(s: &str) -> Option { if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") { return Some(CodeLang::React); } CodeLang::ALL.into_iter().find(|l| l.id().eq_ignore_ascii_case(s) || l.label().eq_ignore_ascii_case(s)) } } /// Layers deeper than this (or past this many children) are summarised. const MAX_DEPTH: usize = 12; const MAX_CHILDREN: usize = 200; /// `v` without trailing zeros (2 decimals). pub(crate) fn num(v: f64) -> String { if !v.is_finite() { return "0".into(); } let r = (v * 100.0).round() / 100.0; if (r - r.round()).abs() < 1e-9 { // `as i64` saturates past 2^63, so larger whole values are printed from the float. if r.abs() < 9e18 { format!("{}", r.round() as i64) } else { format!("{:.0}", r.round()) } } else { format!("{r}") } } /// `code` with every pixel length (`12px`) in rem of `root` pixels (`0.75rem`); names that end /// in "px", markup lines (layer text), string literals and comments are left alone. pub fn px_to_rem(code: &str, root: f64) -> String { let mut out = String::with_capacity(code.len()); let mut in_comment = false; for line in code.split_inclusive('\n') { if !in_comment && line.trim_start().starts_with('<') { out.push_str(line); continue; } // Lengths outside strings and comments convert; `plain` is where that stretch starts. let mut plain = 0; let mut chars = line.char_indices().peekable(); while let Some((i, c)) = chars.next() { if in_comment { if c == '*' && chars.peek().is_some_and(|(_, n)| *n == '/') { chars.next(); out.push_str(line.get(plain..i + 2).unwrap_or("")); plain = i + 2; in_comment = false; } } else if c == '/' && chars.peek().is_some_and(|(_, n)| *n == '*') { chars.next(); out.push_str(&lengths_to_rem(line.get(plain..i).unwrap_or(""), root)); plain = i; in_comment = true; } else if c == '"' { out.push_str(&lengths_to_rem(line.get(plain..i).unwrap_or(""), root)); let mut end = line.len(); while let Some((j, s)) = chars.next() { if s == '\\' { chars.next(); } else if s == '"' { end = j + 1; break; } } out.push_str(line.get(i..end).unwrap_or("")); plain = end; } } let rest = line.get(plain..).unwrap_or(""); if in_comment { out.push_str(rest); } else { out.push_str(&lengths_to_rem(rest, root)); } } out } /// `code` with every pixel length in rem of `root` pixels; names that end in "px" are left alone. fn lengths_to_rem(code: &str, root: f64) -> String { let ident = |c: char| c.is_alphanumeric() || c == '_'; let mut out = String::with_capacity(code.len()); let mut rest = code; while let Some(i) = rest.find("px") { let (head, tail) = (rest.get(..i).unwrap_or(""), rest.get(i + 2..).unwrap_or("")); // The number right before "px". let start = head.char_indices().rev().take_while(|(_, c)| c.is_ascii_digit() || *c == '.' || *c == '-').last().map_or(head.len(), |(j, _)| j); let (before, number) = (head.get(..start).unwrap_or(""), head.get(start..).unwrap_or("")); let free = !before.chars().next_back().is_some_and(ident) && !tail.chars().next().is_some_and(|c| ident(c) || c == '-'); match number.parse::() { Ok(v) if free && v.is_finite() => { out.push_str(before); out.push_str(&format!("{}rem", num(v / root))); } _ => { out.push_str(head); out.push_str("px"); } } rest = tail; } out.push_str(rest); out } pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String { match lang { CodeLang::Css => css(doc, id), CodeLang::Html => html(doc, id), CodeLang::Tailwind => crate::tailwind::code(doc, id, None), CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang), } } /// What a tree walk generates for: the document, the layer the code is for (instances under it /// become calls), and the component whose properties are parameters. pub(crate) struct Ctx<'a> { pub doc: &'a Document, pub root: NodeId, pub comp: Option<&'a Component>, /// Indentation the walk starts at (the depth cap counts from it). pub base: usize, } impl<'a> Ctx<'a> { pub fn new(doc: &'a Document, root: NodeId) -> Self { Ctx { doc, root, comp: None, base: 0 } } /// The component parameter controlling `field` ("visible", "characters", "mainComponent", /// "slot") of `n`. pub fn param(&self, n: &Node, field: &str) -> Option<&'a Param> { let name = n.prop_refs.get(field)?; self.comp?.params.iter().find(|p| &p.prop == name) } /// Visible children in paint order (bottom first), capped; in component code, hidden layers a /// boolean parameter shows are included. pub fn kids(&self, n: &Node) -> Vec { n.children .iter() .copied() .filter(|c| self.doc.node(*c).is_some_and(|k| k.visible || self.param(k, "visible").is_some())) .take(MAX_CHILDREN) .collect() } pub(crate) fn too_deep(&self, depth: usize) -> bool { depth > self.base + MAX_DEPTH } /// A call for an instance below the root (`None` for other layers, or a missing main). fn call(&self, n: &Node, lang: CodeLang, depth: usize) -> Option { if n.id == self.root || !matches!(n.kind, NodeKind::Instance { .. }) { return None; } crate::component_codegen::usage(self.doc, n.id, lang, depth) } } fn first_fill(n: &Node) -> Option { n.fills.iter().filter(|p| p.visible).find_map(|p| match &p.kind { PaintKind::Solid { color } => Some(color.with_alpha(color.a * p.opacity)), PaintKind::Gradient(g) => g.sorted_stops().first().map(|s| s.color), PaintKind::Image(_) | PaintKind::Pattern(_) => None, }) } fn stroke_color(n: &Node) -> Option { if !n.has_visible_stroke() { return None; } n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color()) } /// The CSS borders a solid stroke makes: one `border`, or with individual stroke sides a border /// on each side that has a weight. fn css_borders(doc: &Document, n: &Node, t: &mut Tokens) -> Vec<(String, String)> { let Some(c) = stroke_color(n) else { return Vec::new() }; let Some(sides) = n.stroke.sides else { let weight = tok(doc, n, &["strokeWeight"], format!("{}px", num(n.stroke.weight)), t); return vec![("border".into(), format!("{weight} solid {}", tok(doc, n, &["stroke"], c.to_css(), t)))]; }; let color = tok(doc, n, &["stroke"], c.to_css(), t); let named = ["top", "right", "bottom", "left"].into_iter().zip(crate::cmd::props::STROKE_SIDES); named .zip(sides) .filter(|(_, weight)| *weight > 0.0) .map(|((side, field), weight)| { (format!("border-{side}"), format!("{} solid {color}", tok(doc, n, &[field], format!("{}px", num(weight)), t))) }) .collect() } fn drop_shadow(n: &Node) -> Option<&Shadow> { n.effects.iter().filter(|e| e.visible).find_map(|e| match &e.kind { EffectKind::DropShadow(s) => Some(s), _ => None, }) } fn radius(n: &Node) -> f64 { n.corners.uniform().unwrap_or_else(|| n.corners.radii.iter().copied().fold(0.0, f64::max)) } /// Text content as a string literal (escaped for Swift; Kotlin also escapes `$`). pub(crate) fn literal(s: &str, kotlin: bool) -> String { let mut out = String::from("\""); for c in s.chars().take(10_000) { match c { '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), '\n' | '\u{2028}' => out.push_str("\\n"), '\r' => {} '\t' => out.push_str("\\t"), '$' if kotlin => out.push_str("\\$"), c if c.is_control() => {} c => out.push(c), } } out.push('"'); out } pub(crate) fn indent(depth: usize) -> String { " ".repeat(depth) } // ------------------------------------------------------------------ CSS /// The CSS custom properties generated code uses: name → fallback value, in first-use order. #[derive(Default)] pub(crate) struct Tokens { vars: Vec<(String, String)>, /// Name variables as Tailwind theme variables (`--color-accent`), not by their CSS name. pub tailwind: bool, /// The file's theme names (computed on first use). theme: Option, } impl Tokens { /// `var(--name, value)`, remembering the variable. fn var(&mut self, doc: &Document, variable: &Variable, value: String) -> String { let name = if self.tailwind { self.theme.get_or_insert_with(|| crate::tailwind::Theme::new(doc)).name(variable) } else { css_var_of(variable) }; let out = format!("var({name}, {value})"); if !self.vars.iter().any(|(n, _)| *n == name) { self.vars.push((name, value)); } out } /// A `:root` rule declaring the variables (empty without any). pub fn root_rule(&self) -> String { if self.vars.is_empty() { return String::new(); } let decls: String = self.vars.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect(); format!(":root {{\n{decls}}}\n") } } /// A variable's CSS custom property name: "Colors/Brand 500" → `--colors-brand-500`. pub fn css_var_name(variable: &str) -> String { let mut out = String::from("--"); for c in variable.chars().flat_map(char::to_lowercase).take(80) { if c.is_alphanumeric() || c == '_' { out.push(c); } else if !out.ends_with('-') { out.push('-'); } } let trimmed = out.trim_end_matches('-'); if trimmed.len() <= 2 { "--variable".into() } else { trimmed.to_string() } } /// A variable's CSS custom property: its Web code syntax (`var(--x)` or `--x`) when it has one, /// else made from its name. pub fn css_var_of(v: &Variable) -> String { let Some(web) = v.code_syntax.get("WEB").map(|w| w.trim()) else { return css_var_name(&v.name) }; let inner = web.strip_prefix("var(").map_or(web, |r| r.split([',', ')']).next().unwrap_or("").trim()); let valid = inner.len() > 2 && inner.starts_with("--") && inner.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_'); if valid { inner.chars().take(80).collect() } else { css_var_name(inner) } } /// `value`, or a reference to the variable bound to the first of `fields` that has one. fn tok(doc: &Document, n: &Node, fields: &[&str], value: String, t: &mut Tokens) -> String { match fields.iter().find_map(|f| crate::devinspect::bound_variable(doc, n, f)) { Some(v) => t.var(doc, v, value), None => value, } } fn css(doc: &Document, id: NodeId) -> String { let Some(n) = doc.node(id) else { return String::new() }; let mut t = Tokens::default(); let decls = css_decls(doc, n, &mut t, (false, false)); format!("/* {} */\n{decls}", n.name.replace("*/", "* /")) } /// The CSS declarations for `n`'s own look and size, one per line; `hug` leaves out the width /// and/or height (they follow the content). Bound variables become `var(--name, value)`. pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bool)) -> String { let px = |v: f64| format!("{}px", num(v)); let mut out = String::new(); let mut put = |key: &str, value: String| out.push_str(&format!("{key}: {value};\n")); if !hug.0 { put("width", tok(doc, n, &["width"], px(n.width), t)); } if !hug.1 { put("height", tok(doc, n, &["height"], px(n.height), t)); } if let Some(l) = &n.layout { put("display", "flex".into()); put("flex-direction", if l.mode == LayoutMode::Vertical { "column" } else { "row" }.into()); if l.wrap { put("flex-wrap", "wrap".into()); } put("gap", tok(doc, n, &["gap"], px(l.gap), t)); let [pt, pr, pb, pl] = l.padding; let sides = [ tok(doc, n, &["paddingTop", "paddingVertical", "padding"], px(pt), t), tok(doc, n, &["paddingRight", "paddingHorizontal", "padding"], px(pr), t), tok(doc, n, &["paddingBottom", "paddingVertical", "padding"], px(pb), t), tok(doc, n, &["paddingLeft", "paddingHorizontal", "padding"], px(pl), t), ]; put("padding", sides.join(" ")); let justify = match l.main_align { MainAlign::Min => "flex-start", MainAlign::Center => "center", MainAlign::Max => "flex-end", MainAlign::SpaceBetween => "space-between", MainAlign::SpaceAround => "space-around", MainAlign::SpaceEvenly => "space-evenly", }; put("justify-content", justify.into()); let align = match l.cross_align { CrossAlign::Min => "flex-start", CrossAlign::Center => "center", CrossAlign::Max => "flex-end", CrossAlign::Baseline => "baseline", }; put("align-items", align.into()); if l.wrap { // The lines: spread, or packed where the items align (baseline: at the start). let spread = l.content_align == ContentAlign::SpaceBetween; put( "align-content", if spread { "space-between" } else if align == "baseline" { "flex-start" } else { align } .into(), ); } } if matches!(n.kind, NodeKind::Ellipse { .. }) { put("border-radius", "50%".into()); } else if let Some(r) = n.corners.uniform().filter(|r| *r > 0.0) { put("border-radius", tok(doc, n, &["cornerRadius"], px(r), t)); } else if n.corners.radii.iter().any(|r| *r > 0.0) { let fields = ["topLeftRadius", "topRightRadius", "bottomRightRadius", "bottomLeftRadius"]; let corners: Vec = n.corners.radii.iter().zip(fields).map(|(r, f)| tok(doc, n, &[f, "cornerRadius"], px(*r), t)).collect(); put("border-radius", corners.join(" ")); } if n.opacity < 1.0 { put("opacity", tok(doc, n, &["opacity"], num(f64::from(n.opacity)), t)); } if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) { put("mix-blend-mode", n.blend.css().into()); } // Rotation turns counter-clockwise about the top-left corner (the position given); CSS // rotate turns clockwise. let rot = figcraft_geom::rotation_degrees(n.transform); if rot.abs() > 0.01 && rot.is_finite() { put("transform", format!("rotate({}deg)", num(-rot))); put("transform-origin", "top left".into()); } for (i, p) in n.fills.iter().enumerate().filter(|(_, p)| p.visible) { match &p.kind { PaintKind::Solid { color } => { let key = if matches!(n.kind, NodeKind::Text(_)) { "color" } else { "background" }; let c = color.with_alpha(color.a * p.opacity).to_css(); // A variable binds the first paint's colour. put(key, if i == 0 { tok(doc, n, &["fill"], c, t) } else { c }); } PaintKind::Gradient(g) => put("background", css_gradient(g, n.width, n.height)), PaintKind::Image(_) => put("background", "url(image.png) center / cover".into()), PaintKind::Pattern(_) => put("background", "url(pattern.png) repeat".into()), } } for (key, value) in css_borders(doc, n, t) { put(&key, value); } let shadows: Vec = n .effects .iter() .filter(|e| e.visible) .filter_map(|e| match &e.kind { EffectKind::DropShadow(s) => { Some(format!("{}px {}px {}px {}px {}", num(s.offset.0), num(s.offset.1), num(s.radius), num(s.spread), s.color.to_css())) } EffectKind::InnerShadow(s) => { Some(format!("inset {}px {}px {}px {}px {}", num(s.offset.0), num(s.offset.1), num(s.radius), num(s.spread), s.color.to_css())) } _ => None, }) .collect(); if !shadows.is_empty() { put("box-shadow", shadows.join(", ")); } for e in n.effects.iter().filter(|e| e.visible) { match e.kind { // CSS blurs are uniform: a progressive blur has no CSS equivalent and is left out. EffectKind::LayerBlur(ref b) if !b.is_progressive() => put("filter", format!("blur({}px)", num(b.radius / 2.0))), EffectKind::BackgroundBlur(ref b) if !b.is_progressive() => put("backdrop-filter", format!("blur({}px)", num(b.radius / 2.0))), // CSS has no refraction: the frost is the nearest. EffectKind::Glass(ref g) => put("backdrop-filter", format!("blur({}px)", num(g.sanitized().frost / 2.0))), _ => {} } } if let NodeKind::Text(tx) = &n.kind { let s = &tx.style; put("font-family", tok(doc, n, &["fontFamily"], format!("\"{}\"", s.font_family.replace('"', "'")), t)); put("font-size", tok(doc, n, &["fontSize"], px(s.font_size), t)); put("font-weight", s.weight().to_string()); if s.italic() { put("font-style", "italic".into()); } match s.line_height { LineHeight::Pixels(v) => put("line-height", tok(doc, n, &["lineHeight"], px(v), t)), LineHeight::Percent(p) => put("line-height", format!("{}%", num(p))), LineHeight::Auto => {} } match s.letter_spacing { LetterSpacing::Pixels(v) if v != 0.0 => put("letter-spacing", tok(doc, n, &["letterSpacing"], px(v), t)), LetterSpacing::Percent(p) if p != 0.0 => put("letter-spacing", format!("{}em", num(p / 100.0))), _ => {} } match tx.align_h { TextAlignH::Left => {} TextAlignH::Center => put("text-align", "center".into()), TextAlignH::Right => put("text-align", "right".into()), TextAlignH::Justified => put("text-align", "justify".into()), } match s.case { TextCase::Original => {} TextCase::Upper => put("text-transform", "uppercase".into()), TextCase::Lower => put("text-transform", "lowercase".into()), TextCase::Title => put("text-transform", "capitalize".into()), TextCase::SmallCaps => put("font-variant", "small-caps".into()), } match s.decoration { TextDecoration::None => {} TextDecoration::Underline => put("text-decoration", "underline".into()), TextDecoration::Strikethrough => put("text-decoration", "line-through".into()), } if s.decoration != TextDecoration::None { let l = s.look(); let len = |m: LineHeight| match m { LineHeight::Auto => None, LineHeight::Pixels(v) => Some(px(v)), LineHeight::Percent(p) => Some(format!("{}%", num(p))), }; match l.style { figcraft_doc::DecorationStyle::Solid => {} figcraft_doc::DecorationStyle::Dotted => put("text-decoration-style", "dotted".into()), figcraft_doc::DecorationStyle::Wavy => put("text-decoration-style", "wavy".into()), } if let Some(v) = len(l.thickness) { put("text-decoration-thickness", v); } if let Some(c) = l.color { put("text-decoration-color", c.to_css()); } if s.decoration == TextDecoration::Underline { if let Some(v) = len(l.offset) { put("text-underline-offset", v); } // Browsers skip ink by default; the canvas only when asked. if !l.skip_ink { put("text-decoration-skip-ink", "none".into()); } } } if tx.leading_trim == figcraft_doc::LeadingTrim::CapHeight { put("text-box", "trim-both cap alphabetic".into()); } } out } /// A gradient fill on a `w`×`h` box as a CSS background: linear along its handles' direction /// (CSS angles: 0deg up, 90deg right), radial with its handles' radii, angular as a conic /// gradient from its start direction. fn css_gradient(g: &figcraft_color::Gradient, w: f64, h: f64) -> String { let stops = g.sorted_stops().iter().map(|s| format!("{} {}%", s.color.to_css(), (s.position * 100.0).round())).collect::>().join(", "); let (dx, dy) = ((g.to.0 - g.from.0) * w, (g.to.1 - g.from.1) * h); let angle = if dx == 0.0 && dy == 0.0 { 180.0 } else { dx.atan2(-dy).to_degrees().rem_euclid(360.0) }; let pct = |v: f64| format!("{}%", num(v * 100.0)); let at = format!("at {} {}", pct(g.from.0), pct(g.from.1)); match g.kind { figcraft_color::GradientKind::Linear => format!("linear-gradient({}deg, {stops})", num(angle)), figcraft_color::GradientKind::Radial | figcraft_color::GradientKind::Diamond => { let (rx, ry) = (((g.width.0 - g.from.0) * w).hypot((g.width.1 - g.from.1) * h), dx.hypot(dy)); let rel = |r: f64, side: f64| if side > 0.0 { pct(r / side) } else { "50%".into() }; format!("radial-gradient({} {} {at}, {stops})", rel(rx, w), rel(ry, h)) } figcraft_color::GradientKind::Angular => format!("conic-gradient(from {}deg {at}, {stops})", num(angle)), } } // ------------------------------------------------------------------ HTML and React markup /// A CSS class name from a layer name ("Card / Title" → "card-title"), unique in `used`. pub(crate) fn class_name(name: &str, used: &mut Vec) -> String { let mut base = String::new(); for c in name.chars().flat_map(char::to_lowercase) { if c.is_ascii_alphanumeric() { base.push(c); } else if !base.ends_with('-') && !base.is_empty() { base.push('-'); } } let base = base.trim_end_matches('-').chars().take(40).collect::(); let base = if base.is_empty() || base.starts_with(|c: char| c.is_ascii_digit()) { format!("layer-{base}") } else { base }; let name = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}-{k}") }).find(|n| !used.contains(n)).unwrap_or(base); used.push(name.clone()); name } fn html_escape(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('\n', "
") } /// Text as JSX children: HTML escapes, braces as expressions, line breaks as `
`. fn jsx_text(s: &str) -> String { let mut out = String::new(); for c in s.chars().take(10_000) { match c { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '{' => out.push_str("{\"{\"}"), '}' => out.push_str("{\"}\"}"), '\n' => out.push_str("
"), '\r' => {} c => out.push(c), } } out } /// The whole tree under `id` as nested HTML with a stylesheet: auto layout frames become /// flexboxes, other children are placed absolutely in their parent, text becomes paragraphs. fn html(doc: &Document, id: NodeId) -> String { let ctx = Ctx::new(doc, id); let mut out = Markup::new(&ctx, CodeLang::Html); out.node(id, None, 0, ""); format!("{}\n\n", out.markup, out.tokens.root_rule(), out.styles) } /// Markup being built for a layer tree: HTML (`class`), React JSX (`className`) or Leptos `view!` /// markup (text as Rust string literals); the last two with component parameters and instances /// as calls. With its stylesheet. pub(crate) struct Markup<'c, 'a> { ctx: &'c Ctx<'a>, /// Html, React or Leptos. lang: CodeLang, used: Vec, pub markup: String, pub styles: String, pub tokens: Tokens, /// The root's class (default: from its name) and extra JSX attributes. pub root_class: Option, pub root_attrs: String, /// Each layer's path in the tree (names with sibling counts), class and declarations, so /// variants can be compared layer by layer. pub rules: Vec<(String, String, String)>, /// Instances as their own (overridden) layers instead of component calls, except those /// connected to code (page code). pub inline: bool, /// Auto layout frames and text as fluid boxes (`width: 100%` up to their width) for pages. pub fluid: bool, /// With `fluid`, other layers at least this share of the root's width are fluid too (a /// divider, a banner image); smaller ones (icons) keep their size. Default: none. pub fluid_leaves: f64, } impl<'c, 'a> Markup<'c, 'a> { pub fn new(ctx: &'c Ctx<'a>, lang: CodeLang) -> Self { Markup { ctx, lang, used: Vec::new(), markup: String::new(), styles: String::new(), tokens: Tokens::default(), root_class: None, root_attrs: String::new(), rules: Vec::new(), inline: false, fluid: false, fluid_leaves: f64::INFINITY, } } /// JSX indents by two spaces, HTML and Leptos by four. fn pad(&self, depth: usize) -> String { if self.lang == CodeLang::React { " ".repeat(depth) } else { indent(depth) } } /// Component code (React or Leptos): parameters and instances as calls. fn code(&self) -> bool { self.lang != CodeLang::Html } /// A parameter's name in the generated code. fn ident(&self, p: &Param) -> String { if self.lang == CodeLang::Leptos { crate::component_codegen::rust_ident(&p.ident) } else { p.ident.clone() } } /// A layer, shown only while its boolean parameter is on (component code). pub fn node(&mut self, id: NodeId, parent: Option<&Node>, depth: usize, path: &str) { let Some(n) = self.ctx.doc.node(id) else { return }; if self.code() && id != self.ctx.root && let Some(p) = self.ctx.param(n, "visible") { let (pad, p) = (self.pad(depth), self.ident(p)); let (open, close) = if self.lang == CodeLang::Leptos { (format!("{{{p}.then(|| view! {{"), "})}") } else { (format!("{{{p} && ("), ")}") }; self.markup.push_str(&format!("{pad}{open}\n")); self.element(n, parent, depth + 1, path); self.markup.push_str(&format!("{pad}{close}\n")); return; } self.element(n, parent, depth, path); } fn rule(&mut self, path: &str, class: &str, decls: String) { self.styles.push_str(&format!(".{class} {{\n")); for line in decls.lines() { self.styles.push_str(&format!(" {line}\n")); } self.styles.push_str("}\n"); self.rules.push((path.to_string(), class.to_string(), decls)); } fn element(&mut self, n: &Node, parent: Option<&Node>, depth: usize, path: &str) { let doc = self.ctx.doc; let root = n.id == self.ctx.root; let class = match self.root_class.take().filter(|_| root) { Some(c) => { self.used.push(c.clone()); c } None => class_name(&n.name, &mut self.used), }; let pad = self.pad(depth); let attr = if self.lang == CodeLang::React { "className" } else { "class" }; // How it sits in its parent: in the flex flow, or placed at its position. let mut decls = String::new(); let in_flow = parent.is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute; if root { decls.push_str("position: relative;\n"); } else if in_flow { let vertical = parent.and_then(|p| p.layout.as_ref()).is_some_and(|l| l.mode == LayoutMode::Vertical); let (main, cross) = if vertical { (n.child_layout.sizing_v, n.child_layout.sizing_h) } else { (n.child_layout.sizing_h, n.child_layout.sizing_v) }; if main == Sizing::Fill { decls.push_str("flex: 1 1 0;\n"); } if cross == Sizing::Fill { decls.push_str("align-self: stretch;\n"); } } else { let c = n.transform.as_coeffs(); decls.push_str(&format!("position: absolute;\nleft: {}px;\ntop: {}px;\n", num(c[4]), num(c[5]))); let rot = figcraft_geom::rotation_degrees(n.transform); if rot.abs() > 0.01 { decls.push_str(&format!("transform: rotate({}deg);\ntransform-origin: 0 0;\n", num(-rot))); } } // Nested instances are calls (component code), in a wrapper that places them when needed. // Page code inlines instances, except those connected to code (codeConnect.set): their // snippet, e.g. a design-system `