use super::css::{Tokens, css_decls}; use super::{CodeLang, Ctx, Document, LayoutMode, Node, NodeId, NodeKind, Param, Sizing, indent, num, with_v_if}; // ------------------------------------------------------------------ HTML and React markup /// A word for the node's kind, naming layers whose name transliterates to nothing ("###" → /// `.text`, `.frame`), instead of the anonymous `layer-`. fn kind_word(n: &Node) -> &str { match &n.kind { NodeKind::Page { .. } => "page", NodeKind::Frame => "frame", NodeKind::Group => "group", NodeKind::Section { .. } => "section", NodeKind::Rectangle => "rectangle", NodeKind::Ellipse { .. } => "ellipse", NodeKind::Polygon { .. } => "polygon", NodeKind::Star { .. } => "star", NodeKind::Line => "line", NodeKind::Vector { .. } => "vector", NodeKind::Text(_) => "text", NodeKind::BooleanOp { .. } => "boolean", NodeKind::Component { .. } | NodeKind::ComponentSet { .. } => "component", NodeKind::Instance { .. } => "instance", NodeKind::Slice => "slice", } } /// A CSS class name from a layer name ("Card / Title" → "card-title"), unique in `used`. The name /// is transliterated to ASCII first, so non-Latin names stay readable: 로그인 화면 → /// `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` (per-codepoint transliteration, the slug /// libraries' way); a name left with nothing (symbols only) takes `kind`, and one starting with a /// digit keeps the `layer-` prefix. pub(crate) fn class_name(name: &str, kind: &str, used: &mut Vec) -> String { let mut base = String::new(); for c in deunicode::deunicode(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('-'); } } // The 40-character cut lands on a word boundary when there is one past the first word. let base = if base.chars().count() > 40 { let cut: String = base.chars().take(40).collect(); match cut.rfind('-') { Some(i) if i > 0 => cut[..i].to_string(), _ => cut, } } else { base }; let base = base.trim_end_matches('-'); let base = if base.is_empty() { kind.to_string() } else if base.starts_with(|c: char| c.is_ascii_digit()) { format!("layer-{base}") } else { base.to_string() }; 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. pub(super) 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`), Vue /// (`{{ }}` interpolation, `v-if`, `:attr` bindings) or Leptos `view!` markup (text as Rust /// string literals); the last three with component parameters and instances as calls. With its /// stylesheet. pub(crate) struct Markup<'c, 'a> { ctx: &'c Ctx<'a>, /// Html, React, Vue 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, /// A Vue `v-if` guard for the element about to be written: Vue templates can't wrap an /// element in an expression, so a boolean parameter's layer carries the condition itself. v_if: Option, /// A prefix for every generated class name (slot content joins the caller's stylesheet, where /// two slots' layer names may repeat). pub class_prefix: Option, /// 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, } /// How `n` sits in its parent: in the flex flow, or placed at its position. fn placement_decls(n: &Node, parent: Option<&Node>, root: bool, in_flow: bool) -> String { let mut decls = String::new(); 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))); } } decls } 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(), v_if: None, class_prefix: None, rules: Vec::new(), inline: false, fluid: false, fluid_leaves: f64::INFINITY, } } /// JSX and Vue templates indent by two spaces, HTML and Leptos by four. fn pad(&self, depth: usize) -> String { if matches!(self.lang, CodeLang::React | CodeLang::Vue) { " ".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") { // A Vue template holds no arbitrary expressions, so the guard rides on the element. if self.lang == CodeLang::Vue { self.v_if = Some(self.ident(p)); self.element(n, parent, depth, path); self.v_if = None; return; } 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)); } /// An instance as a call to its component (see `element`); true when it was written. fn instance_call(&mut self, n: &Node, depth: usize, path: &str, class: &str, decls: &str, root: bool) -> bool { let pad = self.pad(depth); let attr = if self.lang == CodeLang::React { "className" } else { "class" }; let connected = self.inline && crate::code_connect::link(self.ctx.doc, n.id, CodeLang::React).is_some(); if self.code() && (!self.inline || connected) && !root && matches!(n.kind, NodeKind::Instance { .. }) { let at = depth + usize::from(!decls.is_empty()); let run = if self.lang == CodeLang::Leptos { ".run()" } else { "" }; let (inner, css) = match self.ctx.param(n, "mainComponent") { Some(p) if self.lang == CodeLang::Vue => (Some(format!("{}\n", self.pad(at), self.ident(p))), String::new()), Some(p) => (Some(format!("{}{{{}{run}}}\n", self.pad(at), self.ident(p))), String::new()), None => self.ctx.call(n, self.lang, at).map_or((None, String::new()), |(code, css)| (Some(code), css)), }; self.styles.push_str(&css); if let Some(inner) = inner { // A Vue guard set by `node` rides on the call when there is no wrapper. let guard = if self.lang == CodeLang::Vue { self.v_if.take() } else { None }; if decls.is_empty() { self.markup.push_str(&with_v_if(&inner, guard.as_deref())); } else { let guard = guard.map(|g| format!(" v-if=\"{g}\"")).unwrap_or_default(); self.markup.push_str(&format!("{pad}
\n{inner}{pad}
\n")); self.rule(path, class, decls.to_string()); } return true; } } false } fn text_body(&self, n: &Node, t: &figcraft_doc::TextData) -> String { match (self.ctx.param(n, "characters").filter(|_| self.code()), self.lang) { (Some(p), CodeLang::Leptos) => format!("{{{}.clone()}}", self.ident(p)), (Some(p), CodeLang::Vue) => format!("{{{{ {} }}}}", self.ident(p)), (Some(p), _) => format!("{{{}}}", p.ident), (None, CodeLang::React) => jsx_text(&t.characters), // `view!` takes text as string literals and void elements self-closed. (None, CodeLang::Leptos) => { t.characters.chars().take(10_000).collect::().lines().map(|l| format!("{l:?}")).collect::>().join("
") } // A Vue template reads `{{ }}` as interpolation, so braces are escaped. (None, CodeLang::Vue) => html_escape(&t.characters).replace('{', "{").replace('}', "}"), (None, _) => html_escape(&t.characters), } } 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 => { let class = class_name(&n.name, kind_word(n), &mut self.used); match &self.class_prefix { Some(prefix) => format!("{prefix}{class}"), None => class, } } }; 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 in_flow = parent.is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute; let mut decls = placement_decls(n, parent, root, in_flow); // 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 `