Merge pull request #1011 from lucasmartins-ai/feat/tailwind-output

Dev Mode: React + Tailwind page code and Tailwind theme export
This commit is contained in:
BFlat
2026-10-26 09:04:31 +07:00
committed by GitHub
6 changed files with 973 additions and 21 deletions
+75 -13
View File
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"inspect.code",
"Code for layer",
"{id?, language?: css|html|react|leptos|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), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
"{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";
@@ -40,7 +40,14 @@ pub fn specs() -> Vec<CommandSpec> {
return Err(bad(cmd, "no such layer"));
}
let connected = crate::code_connect::link(doc, id, lang).is_some();
let code = code_for(doc, id, lang);
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,
@@ -61,18 +68,22 @@ pub enum CodeLang {
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; 6] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose];
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",
}
@@ -83,11 +94,13 @@ impl CodeLang {
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 (they can be shown in rem).
/// 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)
}
@@ -199,6 +212,7 @@ 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),
}
}
@@ -321,25 +335,32 @@ pub(crate) fn indent(depth: usize) -> String {
/// The CSS custom properties generated code uses: name → fallback value, in first-use order.
#[derive(Default)]
pub(crate) struct Tokens(pub Vec<(String, String)>);
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<crate::tailwind::Theme>,
}
impl Tokens {
/// `var(--name, value)`, remembering the variable.
fn var(&mut self, variable: &Variable, value: String) -> String {
let name = css_var_of(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.0.iter().any(|(n, _)| *n == name) {
self.0.push((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.0.is_empty() {
if self.vars.is_empty() {
return String::new();
}
let decls: String = self.0.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect();
let decls: String = self.vars.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect();
format!(":root {{\n{decls}}}\n")
}
}
@@ -370,7 +391,7 @@ pub fn css_var_of(v: &Variable) -> String {
/// `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(v, value),
Some(v) => t.var(doc, v, value),
None => value,
}
}
@@ -664,6 +685,14 @@ pub(crate) struct Markup<'c, 'a> {
/// 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> {
@@ -678,6 +707,9 @@ impl<'c, 'a> Markup<'c, 'a> {
root_class: None,
root_attrs: String::new(),
rules: Vec::new(),
inline: false,
fluid: false,
fluid_leaves: f64::INFINITY,
}
}
@@ -759,7 +791,10 @@ impl<'c, 'a> Markup<'c, 'a> {
}
}
// Nested instances are calls (component code), in a wrapper that places them when needed.
if self.code() && !root && matches!(n.kind, NodeKind::Instance { .. }) {
// Page code inlines instances, except those connected to code (codeConnect.set): their
// snippet, e.g. a design-system `<Button>`.
let connected = self.inline && crate::code_connect::link(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 = match self.ctx.param(n, "mainComponent") {
@@ -779,6 +814,11 @@ impl<'c, 'a> Markup<'c, 'a> {
let hugs = n.layout.is_some() || matches!(n.kind, NodeKind::Text(_));
let hug = (hugs && n.child_layout.sizing_h == Sizing::Hug, hugs && n.child_layout.sizing_v == Sizing::Hug);
let mut own = css_decls(doc, n, &mut self.tokens, hug);
let page = self.ctx.doc.node(self.ctx.root).map_or(f64::INFINITY, |r| r.width);
if self.fluid && (n.layout.is_some() || matches!(n.kind, NodeKind::Text(_)) || (!root && n.width >= page * self.fluid_leaves)) {
// Full width, or sized by the flex row (`flex: 1`): no cap needed.
own = fluid(&own, n.width >= page - 0.5 || decls.contains("flex: 1 1 0;"), n.layout.is_some());
}
if n.layout.is_none() && !n.children.is_empty() && !root && in_flow {
own.push_str("position: relative;\n");
}
@@ -845,6 +885,28 @@ impl<'c, 'a> Markup<'c, 'a> {
}
}
/// `decls` for a box that follows the page: a fixed width becomes `width: 100%` capped at that
/// width (or just the full width for boxes as wide as the page), and a frame's fixed height a
/// minimum, so content can wrap and grow.
// ponytail: width-based heuristic; fixed-size leaves (icons, images, shapes) keep their pixels.
fn fluid(decls: &str, full: bool, frame: bool) -> String {
let mut out = String::with_capacity(decls.len() + 32);
for line in decls.lines() {
if let Some(w) = line.strip_prefix("width: ") {
out.push_str("width: 100%;\n");
if !full {
out.push_str(&format!("max-width: {w}\n"));
}
} else if let Some(h) = line.strip_prefix("height: ").filter(|_| frame) {
out.push_str(&format!("min-height: {h}\n"));
} else {
out.push_str(line);
out.push('\n');
}
}
out
}
// ------------------------------------------------------------------ SwiftUI
fn swift_color(c: Color) -> String {