mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-03 08:49:37 -06:00
M6: Dev Mode component code, annotations and ready for dev (#80)
* M6: Dev Mode component code, annotations and ready for dev - Component code: React (new), SwiftUI and Compose write components as reusable units whose component properties are parameters (variant unions/enums, boolean flags, text strings, instance swaps as views, slots as optional content); variant sets share one definition (React: data-* rules for the other variants); instances are calls passing only the values they change. Escaped, unique identifiers. `inspect.componentCode` returns the parts. - Annotations: notes with pinned properties on any layer, saved with the file (left out when empty), undoable `annotation.add/edit/remove`, `annotation.list` with live values; drawn as callouts on the canvas in the Inspect tab and edited in its Annotations section. Instances keep their own annotations. - Ready for dev: `inspect.readyForDev` lists frames and sections by handoff status; the Inspect tab sets the status and lists them with a filter. - Shared widgets: a multi-line field and the status dropdown are reused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M6: test that bound variables become CSS custom properties Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M6: regenerate the parity report Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,665 @@
|
||||
//! Component code: a component (or component set) becomes a React, SwiftUI or Jetpack Compose
|
||||
//! component whose properties are its parameters (a variant property → a union or enum, boolean →
|
||||
//! a flag showing the layers bound to it, text → a string, instance swap → a view parameter, slot →
|
||||
//! optional content), and an instance becomes a call passing the values it sets. Variants of a set
|
||||
//! share one definition (the set's first variant); in React their differences become CSS rules on
|
||||
//! `data-*` attributes. Returned by `inspect.componentCode` and used by `inspect.code`.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::codegen::{CodeLang, Ctx, Markup, class_name, compose, indent, literal, swift};
|
||||
use crate::components::{prop_default_value, slot_edited, slots_of, variant_options, variant_props, variant_set};
|
||||
use crate::params::*;
|
||||
use crate::{bad, spec};
|
||||
|
||||
/// What a component property becomes in code.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ParamKind {
|
||||
Boolean,
|
||||
Text,
|
||||
InstanceSwap,
|
||||
/// A variant property with its values.
|
||||
Variant(Vec<String>),
|
||||
Slot,
|
||||
}
|
||||
|
||||
impl ParamKind {
|
||||
pub fn id(&self) -> &'static str {
|
||||
match self {
|
||||
ParamKind::Boolean => "boolean",
|
||||
ParamKind::Text => "text",
|
||||
ParamKind::InstanceSwap => "instanceSwap",
|
||||
ParamKind::Variant(_) => "variant",
|
||||
ParamKind::Slot => "slot",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A component property as a parameter.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Param {
|
||||
/// The property's name.
|
||||
pub prop: String,
|
||||
/// Its name in code: camelCase, unique, never a keyword.
|
||||
pub ident: String,
|
||||
pub kind: ParamKind,
|
||||
/// The default: a bool, a string, a component id (instance swap) or null (slot).
|
||||
pub default: Value,
|
||||
/// Variant properties: the enum's name in SwiftUI (PascalCase, unique, never a type the
|
||||
/// generated code uses); Compose prefixes it with the component's name. Empty otherwise.
|
||||
pub enum_name: String,
|
||||
}
|
||||
|
||||
/// A component as code: the main whose layers are drawn (a set's first variant), its set, its
|
||||
/// name in code and its parameters (variant properties first).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Component {
|
||||
pub comp: NodeId,
|
||||
pub set: Option<NodeId>,
|
||||
pub type_name: String,
|
||||
pub params: Vec<Param>,
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![spec!(
|
||||
"inspect.componentCode",
|
||||
"Component code",
|
||||
"{id? (component, set or instance; default the selection), language?: react|swiftui|compose (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances)}",
|
||||
false,
|
||||
|s, p| {
|
||||
let cmd = "inspect.componentCode";
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component or instance"))?;
|
||||
let lang = match string(p, "language").map(|l| CodeLang::from_id(&l)) {
|
||||
Some(Some(l @ (CodeLang::React | CodeLang::SwiftUi | CodeLang::Compose))) => l,
|
||||
Some(_) => return Err(bad(cmd, "language: react|swiftui|compose")),
|
||||
None => CodeLang::React,
|
||||
};
|
||||
let doc = s.doc()?;
|
||||
let c = main_of(doc, id).and_then(|m| describe(doc, m)).ok_or_else(|| bad(cmd, "not a component, set or instance"))?;
|
||||
let values = instance_values(doc, id, &c);
|
||||
let properties: Vec<Value> = c
|
||||
.params
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut v = json!({"name": p.prop, "param": p.ident, "type": p.kind.id(), "default": p.default});
|
||||
if let Some(vals) = &values {
|
||||
v["value"] = vals.iter().find(|(name, _)| *name == p.prop).map_or(Value::Null, |(_, x)| x.clone());
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let mut out = json!({
|
||||
"component": c.comp, "name": c.type_name, "set": c.set, "properties": properties,
|
||||
"definition": definition(doc, &c, lang), "language": lang.id(),
|
||||
});
|
||||
if values.is_some() {
|
||||
out["usage"] = json!(usage(doc, id, lang, 0));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
)]
|
||||
}
|
||||
|
||||
/// Code for any layer in React, SwiftUI or Compose: an instance's call and its component's
|
||||
/// definition, a component's (or set's) definition, or the layer tree (React wraps it in a
|
||||
/// function component).
|
||||
pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
||||
let Some(n) = doc.node(id) else { return String::new() };
|
||||
let comp = main_of(doc, id).and_then(|m| describe(doc, m));
|
||||
match (&n.kind, comp) {
|
||||
(NodeKind::Instance { .. }, Some(c)) => format!("{}\n{}", usage(doc, id, lang, 0).unwrap_or_default(), definition(doc, &c, lang)),
|
||||
(NodeKind::Component { .. } | NodeKind::ComponentSet { .. }, Some(c)) => definition(doc, &c, lang),
|
||||
_ => match lang {
|
||||
CodeLang::React => react(doc, &Component { comp: id, set: None, type_name: component_type_name(&n.name), params: Vec::new() }),
|
||||
CodeLang::SwiftUi => {
|
||||
let mut out = String::new();
|
||||
swift(&Ctx::new(doc, id), id, 0, &mut out, &[]);
|
||||
out
|
||||
}
|
||||
_ => {
|
||||
let mut out = String::new();
|
||||
compose(&Ctx::new(doc, id), id, 0, &mut out);
|
||||
out
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn is_component(doc: &Document, id: NodeId) -> bool {
|
||||
doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))
|
||||
}
|
||||
|
||||
/// The main component a layer's code is defined by: a component itself, an instance's main, and
|
||||
/// for anything in a set the set's first variant.
|
||||
pub fn main_of(doc: &Document, id: NodeId) -> Option<NodeId> {
|
||||
let n = doc.node(id)?;
|
||||
let main = match &n.kind {
|
||||
NodeKind::Component { .. } => id,
|
||||
NodeKind::ComponentSet { .. } => return n.children.iter().copied().find(|c| is_component(doc, *c)),
|
||||
NodeKind::Instance { component, .. } => *component,
|
||||
_ => return None,
|
||||
};
|
||||
if !is_component(doc, main) {
|
||||
return None;
|
||||
}
|
||||
match variant_set(doc, main) {
|
||||
Some(set) => doc.node(set)?.children.iter().copied().find(|c| is_component(doc, *c)),
|
||||
None => Some(main),
|
||||
}
|
||||
}
|
||||
|
||||
/// `comp` (a component) as code.
|
||||
pub fn describe(doc: &Document, comp: NodeId) -> Option<Component> {
|
||||
let n = doc.node(comp)?;
|
||||
let NodeKind::Component { props, .. } = &n.kind else { return None };
|
||||
let set = variant_set(doc, comp);
|
||||
let type_name = component_type_name(&set.and_then(|s| doc.node(s)).unwrap_or(n).name);
|
||||
let mut used: Vec<String> = Vec::new();
|
||||
let mut enums: Vec<String> = vec![type_name.clone()];
|
||||
let mut params: Vec<Param> = Vec::new();
|
||||
let mut add = |prop: String, kind: ParamKind, default: Value| {
|
||||
let enum_name = if matches!(kind, ParamKind::Variant(_)) { enum_ident(&prop, &mut enums) } else { String::new() };
|
||||
params.push(Param { ident: ident(&prop, &mut used), prop, kind, default, enum_name });
|
||||
};
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
if let Some(set) = set {
|
||||
let own = variant_props(&n.name);
|
||||
for (k, vals) in variant_options(doc, set) {
|
||||
let default = own.get(&k).or_else(|| vals.first()).cloned().unwrap_or_default();
|
||||
seen.push(k.clone());
|
||||
add(k, ParamKind::Variant(vals), Value::String(default));
|
||||
}
|
||||
}
|
||||
for (k, d) in props {
|
||||
if seen.contains(k) {
|
||||
continue;
|
||||
}
|
||||
let kind = match d {
|
||||
PropDef::Boolean { .. } => ParamKind::Boolean,
|
||||
PropDef::Text { .. } => ParamKind::Text,
|
||||
PropDef::InstanceSwap { .. } => ParamKind::InstanceSwap,
|
||||
PropDef::Variant { options, .. } => ParamKind::Variant(options.clone()),
|
||||
PropDef::Slot { .. } => ParamKind::Slot,
|
||||
};
|
||||
add(k.clone(), kind, prop_default_value(d));
|
||||
}
|
||||
Some(Component { comp, set, type_name, params })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ names
|
||||
|
||||
/// ASCII words of a name ("Show icon?" → ["Show", "icon"]).
|
||||
fn words(s: &str) -> Vec<&str> {
|
||||
s.split(|c: char| !c.is_ascii_alphanumeric()).filter(|w| !w.is_empty()).take(12).collect()
|
||||
}
|
||||
|
||||
fn capitalized(w: &str) -> String {
|
||||
let mut c = w.chars();
|
||||
c.next().map(|f| f.to_ascii_uppercase().to_string() + c.as_str()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A type name: "Button / Primary" → `ButtonPrimary`.
|
||||
pub fn type_name(name: &str) -> String {
|
||||
let t: String = words(name).into_iter().map(capitalized).collect::<String>().chars().take(60).collect();
|
||||
match t.chars().next() {
|
||||
None => "Component".into(),
|
||||
Some(c) if c.is_ascii_digit() => format!("Component{t}"),
|
||||
Some(_) => t,
|
||||
}
|
||||
}
|
||||
|
||||
/// Words no generated parameter may be called (keywords of TypeScript, Swift and Kotlin, and
|
||||
/// names the generated code uses).
|
||||
const RESERVED: &[&str] = &[
|
||||
"as",
|
||||
"body",
|
||||
"break",
|
||||
"case",
|
||||
"catch",
|
||||
"class",
|
||||
"const",
|
||||
"continue",
|
||||
"default",
|
||||
"defer",
|
||||
"delete",
|
||||
"do",
|
||||
"else",
|
||||
"enum",
|
||||
"export",
|
||||
"extends",
|
||||
"extension",
|
||||
"false",
|
||||
"for",
|
||||
"fun",
|
||||
"func",
|
||||
"function",
|
||||
"guard",
|
||||
"if",
|
||||
"import",
|
||||
"in",
|
||||
"init",
|
||||
"interface",
|
||||
"is",
|
||||
"key",
|
||||
"let",
|
||||
"modifier",
|
||||
"new",
|
||||
"nil",
|
||||
"null",
|
||||
"object",
|
||||
"operator",
|
||||
"package",
|
||||
"private",
|
||||
"protocol",
|
||||
"public",
|
||||
"ref",
|
||||
"repeat",
|
||||
"return",
|
||||
"self",
|
||||
"static",
|
||||
"struct",
|
||||
"super",
|
||||
"switch",
|
||||
"this",
|
||||
"throw",
|
||||
"true",
|
||||
"try",
|
||||
"typealias",
|
||||
"typeof",
|
||||
"val",
|
||||
"var",
|
||||
"void",
|
||||
"when",
|
||||
"where",
|
||||
"while",
|
||||
"with",
|
||||
"yield",
|
||||
];
|
||||
|
||||
/// Types the generated SwiftUI and Compose code names: a variant enum may not shadow them.
|
||||
const TYPES: &[&str] = &[
|
||||
"Alignment",
|
||||
"AnyView",
|
||||
"Arrangement",
|
||||
"Bool",
|
||||
"Boolean",
|
||||
"Box",
|
||||
"CaseIterable",
|
||||
"CircleShape",
|
||||
"Color",
|
||||
"Column",
|
||||
"Composable",
|
||||
"Edge",
|
||||
"EdgeInsets",
|
||||
"Ellipse",
|
||||
"EmptyView",
|
||||
"Font",
|
||||
"FontWeight",
|
||||
"Group",
|
||||
"HStack",
|
||||
"Image",
|
||||
"Modifier",
|
||||
"RectangleShape",
|
||||
"RoundedCornerShape",
|
||||
"RoundedRectangle",
|
||||
"Row",
|
||||
"Shape",
|
||||
"String",
|
||||
"Text",
|
||||
"Unit",
|
||||
"VStack",
|
||||
"View",
|
||||
"ZStack",
|
||||
];
|
||||
|
||||
/// A component's name in code: [`type_name`], suffixed when it would shadow one of [`TYPES`]
|
||||
/// ("Text" → `TextComponent`).
|
||||
pub fn component_type_name(name: &str) -> String {
|
||||
let t = type_name(name);
|
||||
if TYPES.contains(&t.as_str()) { format!("{t}Component") } else { t }
|
||||
}
|
||||
|
||||
/// A variant enum's name: "Size" → `Size`, unique in `used`, never one of [`TYPES`].
|
||||
fn enum_ident(prop: &str, used: &mut Vec<String>) -> String {
|
||||
let t = type_name(prop);
|
||||
let base = if t == "Component" || TYPES.contains(&t.as_str()) { format!("{t}Variant") } else { t };
|
||||
let out = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}{k}") }).find(|n| !used.contains(n)).unwrap_or(base);
|
||||
used.push(out.clone());
|
||||
out
|
||||
}
|
||||
|
||||
/// A parameter name: "Show icon" → `showIcon`, unique in `used`.
|
||||
fn ident(name: &str, used: &mut Vec<String>) -> String {
|
||||
let mut base = String::new();
|
||||
for (i, w) in words(name).into_iter().enumerate() {
|
||||
base.push_str(&if i == 0 { w.to_ascii_lowercase() } else { capitalized(w) });
|
||||
}
|
||||
let mut base: String = base.chars().take(40).collect();
|
||||
if base.is_empty() {
|
||||
base = "prop".into();
|
||||
} else if base.starts_with(|c: char| c.is_ascii_digit()) {
|
||||
base = format!("p{base}");
|
||||
}
|
||||
if RESERVED.contains(&base.as_str()) {
|
||||
base.push_str("Value");
|
||||
}
|
||||
let out = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}{k}") }).find(|n| !used.contains(n)).unwrap_or(base);
|
||||
used.push(out.clone());
|
||||
out
|
||||
}
|
||||
|
||||
/// Enum case names for variant values, unique: Swift's lowerCamel (`.small`, `` .`default` ``),
|
||||
/// Kotlin's PascalCase (`Small`).
|
||||
fn cases(values: &[String], swift: bool) -> Vec<String> {
|
||||
let mut used = Vec::new();
|
||||
values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
if swift {
|
||||
let id = ident(v, &mut used);
|
||||
// Keyword cases stay readable in backticks rather than suffixed.
|
||||
match id.strip_suffix("Value").filter(|b| RESERVED.contains(b)) {
|
||||
Some(b) => format!("`{b}`"),
|
||||
None => id,
|
||||
}
|
||||
} else {
|
||||
let t = type_name(v);
|
||||
let t = if t == "Component" && !v.is_empty() && words(v).is_empty() { "Value".to_string() } else { t };
|
||||
let out = (1..).map(|k| if k == 1 { t.clone() } else { format!("{t}{k}") }).find(|n| !used.contains(n)).unwrap_or(t);
|
||||
used.push(out.clone());
|
||||
out
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A JavaScript string literal.
|
||||
fn js_string(s: &str) -> String {
|
||||
let s: String = s.chars().take(10_000).collect();
|
||||
serde_json::to_string(&s).unwrap_or_else(|_| "\"\"".into())
|
||||
}
|
||||
|
||||
/// The type name of a component (a variant's set) by id.
|
||||
fn component_type(doc: &Document, comp: NodeId) -> Option<String> {
|
||||
let n = doc.node(comp).filter(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))?;
|
||||
Some(component_type_name(&variant_set(doc, comp).and_then(|s| doc.node(s)).unwrap_or(n).name))
|
||||
}
|
||||
|
||||
/// A Swift enum or Kotlin enum class name for a variant parameter.
|
||||
fn enum_name(c: &Component, p: &Param, swift: bool) -> String {
|
||||
if swift { p.enum_name.clone() } else { format!("{}{}", c.type_name, p.enum_name) }
|
||||
}
|
||||
|
||||
/// `value` of a parameter as an expression in `lang`.
|
||||
fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value, lang: CodeLang) -> String {
|
||||
match (&p.kind, lang) {
|
||||
(ParamKind::Boolean, _) => value.as_bool().unwrap_or(false).to_string(),
|
||||
(ParamKind::Text, CodeLang::React) => js_string(value.as_str().unwrap_or("")),
|
||||
(ParamKind::Text, l) => literal(value.as_str().unwrap_or(""), l == CodeLang::Compose),
|
||||
(ParamKind::Variant(opts), l) => {
|
||||
let v = value.as_str().unwrap_or("");
|
||||
if l == CodeLang::React {
|
||||
return js_string(v);
|
||||
}
|
||||
let swift = l == CodeLang::SwiftUi;
|
||||
let i = opts.iter().position(|o| o == v).unwrap_or(0);
|
||||
let case = cases(opts, swift).get(i).cloned().unwrap_or_else(|| "unknown".into());
|
||||
if swift { format!(".{case}") } else { format!("{}.{case}", enum_name(c, p, false)) }
|
||||
}
|
||||
(ParamKind::InstanceSwap, l) => {
|
||||
let t = id_of(value).and_then(|i| component_type(doc, i));
|
||||
match (t, l) {
|
||||
(Some(t), CodeLang::React) => format!("<{t} />"),
|
||||
(Some(t), CodeLang::SwiftUi) => format!("AnyView({t}())"),
|
||||
(Some(t), _) => format!("{{ {t}() }}"),
|
||||
(None, CodeLang::React) => "null".into(),
|
||||
(None, CodeLang::SwiftUi) => "AnyView(EmptyView())".into(),
|
||||
(None, _) => "{}".into(),
|
||||
}
|
||||
}
|
||||
(ParamKind::Slot, _) => "null".into(),
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ instances
|
||||
|
||||
/// The values an instance sets for `c`'s parameters, in order (`None` for other layers): variant
|
||||
/// values from the variant it shows, slots as whether they hold its own content.
|
||||
fn instance_values(doc: &Document, id: NodeId, c: &Component) -> Option<Vec<(String, Value)>> {
|
||||
let NodeKind::Instance { component, props } = &doc.node(id)?.kind else { return None };
|
||||
let variant = doc.node(*component).map(|m| variant_props(&m.name)).unwrap_or_default();
|
||||
let slots = slots_of(doc, id);
|
||||
Some(
|
||||
c.params
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let v = match &p.kind {
|
||||
ParamKind::Variant(_) if c.set.is_some() => variant.get(&p.prop).map(|v| json!(v)),
|
||||
ParamKind::Slot => Some(json!(slots.iter().any(|(l, name)| *name == p.prop && slot_edited(doc, *l)))),
|
||||
_ => props.get(&p.prop).cloned(),
|
||||
};
|
||||
(p.prop.clone(), v.unwrap_or_else(|| p.default.clone()))
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The call for instance `id` at `depth`, passing the values that differ from the defaults and the
|
||||
/// content of slots it fills (`None` when it isn't an instance of an existing component).
|
||||
pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option<String> {
|
||||
let c = main_of(doc, id).and_then(|m| describe(doc, m))?;
|
||||
let values = instance_values(doc, id, &c)?;
|
||||
let slots = slots_of(doc, id);
|
||||
let pad = if lang == CodeLang::React { " ".repeat(depth) } else { indent(depth) };
|
||||
// (parameter, expression, whether it's a plain string literal)
|
||||
let mut args: Vec<(&str, String, bool)> = Vec::new();
|
||||
for (p, (_, v)) in c.params.iter().zip(&values) {
|
||||
if let ParamKind::Slot = p.kind {
|
||||
if v.as_bool() == Some(true)
|
||||
&& let Some((layer, _)) = slots.iter().find(|(_, name)| *name == p.prop)
|
||||
{
|
||||
args.push((&p.ident, slot_content(doc, *layer, lang, depth), false));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if *v == p.default || (p.kind == ParamKind::InstanceSwap && id_of(v) == id_of(&p.default)) {
|
||||
continue;
|
||||
}
|
||||
let expr = value_expr(doc, &c, p, v, lang);
|
||||
let plain = matches!(p.kind, ParamKind::Text | ParamKind::Variant(_)) && !expr.contains('\\');
|
||||
args.push((&p.ident, expr, plain));
|
||||
}
|
||||
let t = &c.type_name;
|
||||
Some(match lang {
|
||||
CodeLang::React => {
|
||||
// Plain strings read best as attribute values.
|
||||
let attrs: String =
|
||||
args.iter().map(|(name, expr, plain)| if *plain { format!(" {name}={expr}") } else { format!(" {name}={{{expr}}}") }).collect();
|
||||
format!("{pad}<{t}{attrs} />\n")
|
||||
}
|
||||
CodeLang::SwiftUi => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", ")),
|
||||
_ => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n} = {e}")).collect::<Vec<_>>().join(", ")),
|
||||
})
|
||||
}
|
||||
|
||||
/// The content an instance put in one of its slots, as an expression spanning lines.
|
||||
fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize) -> String {
|
||||
let Some(n) = doc.node(slot) else { return String::new() };
|
||||
let ctx = Ctx { doc, root: slot, comp: None, base: depth };
|
||||
let kids = ctx.kids(n);
|
||||
match lang {
|
||||
CodeLang::React => {
|
||||
let mut m = Markup::new(&ctx, true);
|
||||
for k in kids {
|
||||
m.node(k, Some(n), depth + 2, "");
|
||||
}
|
||||
let (p1, p2) = (" ".repeat(depth), " ".repeat(depth + 1));
|
||||
format!("(\n{p2}<>\n{}{p2}</>\n{p1})", m.markup)
|
||||
}
|
||||
CodeLang::SwiftUi => {
|
||||
let mut out = String::new();
|
||||
for k in kids {
|
||||
swift(&ctx, k, depth + 2, &mut out, &[]);
|
||||
}
|
||||
format!("AnyView(Group {{\n{out}{}}})", indent(depth + 1))
|
||||
}
|
||||
_ => {
|
||||
let mut out = String::new();
|
||||
for k in kids {
|
||||
compose(&ctx, k, depth + 2, &mut out);
|
||||
}
|
||||
format!("{{\n{out}{}}}", indent(depth + 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ definitions
|
||||
|
||||
/// `c`'s definition in `lang`.
|
||||
pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
|
||||
match lang {
|
||||
CodeLang::SwiftUi => swift_definition(doc, c),
|
||||
CodeLang::Compose => compose_definition(doc, c),
|
||||
_ => react(doc, c),
|
||||
}
|
||||
}
|
||||
|
||||
/// `data-*` attribute names for variant parameters.
|
||||
fn data_attr(p: &Param) -> String {
|
||||
let mut used = Vec::new();
|
||||
format!("data-{}", class_name(&p.prop, &mut used))
|
||||
}
|
||||
|
||||
/// A TSX function component with its props interface, and its stylesheet (variables as custom
|
||||
/// properties, other variants as rules on their `data-*` attributes).
|
||||
fn react(doc: &Document, c: &Component) -> String {
|
||||
let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 2 };
|
||||
let root_name = c.set.and_then(|s| doc.node(s)).or_else(|| doc.node(c.comp)).map(|n| n.name.clone()).unwrap_or_default();
|
||||
let root_class = class_name(&root_name, &mut Vec::new());
|
||||
let variants: Vec<&Param> = c.params.iter().filter(|p| matches!(p.kind, ParamKind::Variant(_))).collect();
|
||||
let pass = |root: NodeId| {
|
||||
let vctx = Ctx { doc, root, comp: Some(c), base: 2 };
|
||||
let mut m = Markup::new(&vctx, true);
|
||||
m.root_class = Some(root_class.clone());
|
||||
m.node(root, None, 2, "");
|
||||
m.rules
|
||||
};
|
||||
let mut m = Markup::new(&ctx, true);
|
||||
m.root_class = Some(root_class.clone());
|
||||
m.root_attrs = variants.iter().map(|p| format!(" {}={{{}}}", data_attr(p), p.ident)).collect();
|
||||
m.node(c.comp, None, 2, "");
|
||||
// Other variants: what differs from this one, layer by layer.
|
||||
let mut variant_rules = String::new();
|
||||
let others: Vec<NodeId> = c.set.and_then(|s| doc.node(s)).map(|s| s.children.clone()).unwrap_or_default();
|
||||
for v in others.into_iter().filter(|v| *v != c.comp && is_component(doc, *v)).take(64) {
|
||||
let Some(vn) = doc.node(v) else { continue };
|
||||
let pairs = variant_props(&vn.name);
|
||||
let attrs: String = variants
|
||||
.iter()
|
||||
.filter_map(|p| pairs.get(&p.prop).map(|val| format!("[{}=\"{}\"]", data_attr(p), val.replace(['"', '\\'], ""))))
|
||||
.collect();
|
||||
for (path, _, decls) in pass(v) {
|
||||
let Some((_, class, base)) = m.rules.iter().find(|(p, _, _)| *p == path) else { continue };
|
||||
let base_lines: Vec<&str> = base.lines().collect();
|
||||
let diff: Vec<&str> = decls.lines().filter(|l| !base_lines.contains(l)).collect();
|
||||
if diff.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let sel = if path.is_empty() { format!(".{root_class}{attrs}") } else { format!(".{root_class}{attrs} .{class}") };
|
||||
variant_rules.push_str(&format!("{sel} {{\n{}}}\n", diff.iter().map(|l| format!(" {l}\n")).collect::<String>()));
|
||||
}
|
||||
}
|
||||
let t = &c.type_name;
|
||||
let mut out = format!("import \"./{root_class}.css\";\n\n");
|
||||
if c.params.is_empty() {
|
||||
out.push_str(&format!("export function {t}() {{\n"));
|
||||
} else {
|
||||
out.push_str(&format!("export interface {t}Props {{\n"));
|
||||
for p in &c.params {
|
||||
let ty = match &p.kind {
|
||||
ParamKind::Variant(opts) => opts.iter().map(|o| js_string(o)).collect::<Vec<_>>().join(" | "),
|
||||
ParamKind::Boolean => "boolean".into(),
|
||||
ParamKind::Text => "string".into(),
|
||||
ParamKind::InstanceSwap | ParamKind::Slot => "React.ReactNode".into(),
|
||||
};
|
||||
out.push_str(&format!(" {}?: {ty};\n", p.ident));
|
||||
}
|
||||
out.push_str("}\n\n");
|
||||
let args: Vec<String> = c
|
||||
.params
|
||||
.iter()
|
||||
.map(|p| match p.kind {
|
||||
ParamKind::Slot => p.ident.clone(),
|
||||
_ => format!("{} = {}", p.ident, value_expr(doc, c, p, &p.default, CodeLang::React)),
|
||||
})
|
||||
.collect();
|
||||
out.push_str(&format!("export function {t}({{\n{}}}: {t}Props) {{\n", args.iter().map(|a| format!(" {a},\n")).collect::<String>()));
|
||||
}
|
||||
out.push_str(&format!(" return (\n{} );\n}}\n\n/* {root_class}.css */\n{}{}{variant_rules}", m.markup, m.tokens.root_rule(), m.styles));
|
||||
out
|
||||
}
|
||||
|
||||
/// A SwiftUI view struct: variant enums, stored properties with defaults, and the body.
|
||||
fn swift_definition(doc: &Document, c: &Component) -> String {
|
||||
let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 2 };
|
||||
let mut out = format!("struct {}: View {{\n", c.type_name);
|
||||
for p in &c.params {
|
||||
if let ParamKind::Variant(opts) = &p.kind {
|
||||
out.push_str(&format!(" enum {}: String, CaseIterable {{\n", enum_name(c, p, true)));
|
||||
for (o, case) in opts.iter().zip(cases(opts, true)) {
|
||||
out.push_str(&format!(" case {case} = {}\n", literal(o, false)));
|
||||
}
|
||||
out.push_str(" }\n");
|
||||
}
|
||||
}
|
||||
for p in &c.params {
|
||||
let ty = match &p.kind {
|
||||
ParamKind::Variant(_) => enum_name(c, p, true),
|
||||
ParamKind::Boolean => "Bool".into(),
|
||||
ParamKind::Text => "String".into(),
|
||||
ParamKind::InstanceSwap => "AnyView".into(),
|
||||
ParamKind::Slot => "AnyView?".into(),
|
||||
};
|
||||
let default = if p.kind == ParamKind::Slot { "nil".into() } else { value_expr(doc, c, p, &p.default, CodeLang::SwiftUi) };
|
||||
out.push_str(&format!(" var {}: {ty} = {default}\n", p.ident));
|
||||
}
|
||||
if !c.params.is_empty() {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(" var body: some View {\n");
|
||||
swift(&ctx, c.comp, 2, &mut out, &[]);
|
||||
out.push_str(" }\n}\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// A `@Composable` function: variant enum classes, parameters with defaults, and the body.
|
||||
fn compose_definition(doc: &Document, c: &Component) -> String {
|
||||
let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 1 };
|
||||
let mut out = String::new();
|
||||
for p in &c.params {
|
||||
if let ParamKind::Variant(opts) = &p.kind {
|
||||
out.push_str(&format!("enum class {} {{ {} }}\n\n", enum_name(c, p, false), cases(opts, false).join(", ")));
|
||||
}
|
||||
}
|
||||
out.push_str(&format!("@Composable\nfun {}(", c.type_name));
|
||||
if !c.params.is_empty() {
|
||||
out.push('\n');
|
||||
for p in &c.params {
|
||||
let ty = match &p.kind {
|
||||
ParamKind::Variant(_) => enum_name(c, p, false),
|
||||
ParamKind::Boolean => "Boolean".into(),
|
||||
ParamKind::Text => "String".into(),
|
||||
ParamKind::InstanceSwap => "@Composable () -> Unit".into(),
|
||||
ParamKind::Slot => "(@Composable () -> Unit)?".into(),
|
||||
};
|
||||
let default = if p.kind == ParamKind::Slot { "null".into() } else { value_expr(doc, c, p, &p.default, CodeLang::Compose) };
|
||||
out.push_str(&format!(" {}: {ty} = {default},\n", p.ident));
|
||||
}
|
||||
}
|
||||
out.push_str(") {\n");
|
||||
compose(&ctx, c.comp, 1, &mut out);
|
||||
out.push_str("}\n");
|
||||
out
|
||||
}
|
||||
Reference in New Issue
Block a user