/// 导出管道:从 Project 生成可直接使用的代码/原型。
///
/// - export_html:自包含交互式 HTML 原型(无依赖,可直接打开)
/// - export_react:React TSX 组件桩(内联样式,可直接放进 Next.js)
/// - export_tailwind:Tailwind CSS class 映射(可组合进现有项目)
// ---------------------------------------------------------------------------
// HTML 原型导出
// ---------------------------------------------------------------------------
///|
/// 入场画板推断:导航流的天然起点——没有被任何 flow 指向的画板;并列/成环
/// 时按确定性 tie-break 取最小 id(String < 为长度优先的 shortlex,非纯
/// 字典序)。登录→首页型原型因此开在登录页,不再随
/// Map 遍历序漂移到任意画板(精致感缺陷:开屏即错页)。
fn Project::infer_entry(self : Project) -> String {
let targets : Map[String, Bool] = {}
for e in self.flows.iter() {
targets.set(e.to, true)
}
let mut best = ""
for id in self.artboards.keys() {
if !targets.contains(id) && (best == "" || id < best) {
best = id
}
}
if best == "" {
for id in self.artboards.keys() {
if best == "" || id < best {
best = id
}
}
}
best
}
///|
/// 导出面的 JS 字符串转义(仅 export 层收口,不动 core/json.mbt 的
/// json_escape——那是 JSON 协议语义):boot/tab onclick 落在单引号 JS
/// 字符串、MV_ACT/MV_STATES 落在双引号 JS 字符串,且全部内嵌在 从字符串内提前闭合(含大小写变体)。
fn js_string_escape(s : String) -> String {
let sb = StringBuilder::new()
for c in s.iter() {
match c {
'"' => sb.write_string("\\x22")
'\'' => sb.write_string("\\'")
'\\' => sb.write_string("\\\\")
'<' => sb.write_string("\\x3c")
'\n' => sb.write_string("\\n")
'\r' => sb.write_string("\\r")
'\t' => sb.write_string("\\t")
_ => {
let code = c.to_int()
if code < 32 {
// 其余 C0 控制符:\u00xx(编码尾段与 json_escape 共用)
write_c0_u_escape(sb, code)
} else {
sb.write_char(c)
}
}
}
}
sb.to_string()
}
///|
/// 导出为自包含 HTML(SVG 内嵌 + 画板切换 + 导航流模拟)
pub fn Project::export_html(self : Project) -> String {
let sb = StringBuilder::new()
sb.write_string(
"\n\n\n\n",
)
sb.write_string(
"\n",
)
// 暗底原型:声明 color-scheme,浏览器控件/滚动条不再闪白
sb.write_string("\n")
sb.write_string("MoonViz Prototype\n")
sb.write_string("\n\n\n")
// Tab 栏
sb.write_string("\n")
for id in self.artboards.keys() {
match self.artboards.get(id) {
Some(ab) => {
// onclick 的 id 落在单引号 JS 字符串里,必须转义;DOM id 属性保持
// 原样——getElementById('tab-'+id) 按 JS 解析后的串查 DOM,两侧一致
let js_id = js_string_escape(id)
sb.write_string(
"
\{ab.name}
\n",
)
}
None => ()
}
}
sb.write_string("
\n")
// 画板内容
for id in self.artboards.keys() {
match self.artboards.get(id) {
Some(ab) => {
let svg = render_svg(
ab.doc,
surface=self.tokens.resolve_color("surface"),
frame=self.tokens.resolve_color("outline"),
)
sb.write_string(
"\n",
)
sb.write_string(svg)
sb.write_string("\n
\n")
}
None => ()
}
}
// JS
sb.write_string("\n")
sb.write_string("\n\n")
sb.to_string()
}
// ---------------------------------------------------------------------------
// React 组件桩导出
// ---------------------------------------------------------------------------
///|
/// 导出为 React TSX 组件桩(每个画板一个组件,内联样式)
pub fn Project::export_react(self : Project) -> String {
let sb = StringBuilder::new()
sb.write_string("// Auto-generated by MoonViz Agent Engine\n")
sb.write_string("// 每个 Artboard 映射为一个 React 组件\n\n")
for id in self.artboards.keys() {
match self.artboards.get(id) {
Some(ab) => {
let comp_name = to_pascal_case(ab.name)
sb.write_string("export function \{comp_name}() {\n")
sb.write_string(" return (\n")
// 生成 JSX 树
let layout = solve(ab.doc)
write_react_node(sb, ab.doc, ab.doc.root_id, layout, 2)
sb.write_string(" )\n}\n\n")
}
None => ()
}
}
// 导航流映射
if self.flows.length() > 0 {
sb.write_string("// Navigation flows:\n")
for e in self.flows.iter() {
sb.write_string(
"// \{to_pascal_case(e.from)} -> \{to_pascal_case(e.to)} (trigger: \{e.trigger})\n",
)
}
}
sb.to_string()
}
///|
fn write_react_node(
sb : StringBuilder,
doc : Document,
id : String,
layout : SolvedLayout,
indent : Int,
) -> Unit {
let pad = repeat_str(" ", indent)
match (doc.nodes.get(id), layout.rect_of(id)) {
(Some(n), Some(r)) => {
let style = "position:'absolute',left:\{r.x.to_int()},top:\{r.y.to_int()},width:\{r.width.to_int()},height:\{r.height.to_int()}"
let extra_style = react_style(n)
let full_style = if extra_style != "" {
"\{style},\{extra_style}"
} else {
style
}
match n.kind {
Text =>
sb.write_string(
"\{pad}\{jsx_text_escape(n.text)}\n",
)
_ =>
if n.children.length() == 0 {
sb.write_string(
"\{pad}\n",
)
} else {
sb.write_string(
"\{pad}\n",
)
for cid in n.children.iter() {
write_react_node(sb, doc, cid, layout, indent + 1)
}
sb.write_string("\{pad}
\n")
}
}
}
_ => ()
}
}
///|
/// JSX 文本内容转义:& < > { } 按实体转义,防止文本字面量破坏标签/表达式结构
fn jsx_text_escape(s : String) -> String {
let sb = StringBuilder::new()
for c in s.iter() {
match c {
'&' => sb.write_string("&")
'<' => sb.write_string("<")
'>' => sb.write_string(">")
'{' => sb.write_string("{")
'}' => sb.write_string("}")
_ => sb.write_char(c)
}
}
sb.to_string()
}
///|
fn react_style(n : Node) -> String {
let parts : Array[String] = []
if n.style.fill != "none" && n.style.fill != "" {
parts.push("background:'\{n.style.fill}'")
}
if n.style.corner_radius > 0 {
parts.push("borderRadius:\{n.style.corner_radius.to_int()}")
}
if n.style.font_size != 14.0 {
parts.push("fontSize:\{n.style.font_size.to_int()}")
}
if n.style.text_color != "#111111" {
parts.push("color:'\{n.style.text_color}'")
}
if n.style.stroke != "none" && n.style.stroke != "" {
parts.push("border:'1px solid \{n.style.stroke}'")
}
jstr8(parts, ",")
}
///|
fn jstr8(parts : Array[String], sep : String) -> String {
let sb = StringBuilder::new()
for i in 0.. 0 {
sb.write_string(sep)
}
sb.write_string(parts[i])
}
sb.to_string()
}
// ---------------------------------------------------------------------------
// 工具
// ---------------------------------------------------------------------------
///|
fn to_pascal_case(s : String) -> String {
let sb = StringBuilder::new()
let mut upper_next = true
for c in s.iter() {
let code = c.to_int()
if code == 95 || code == 45 || code == 32 { // _ - space
upper_next = true
} else if upper_next {
if code >= 97 && code <= 122 {
sb.write_char(Char::from_int(code - 32))
} else {
sb.write_char(c)
}
upper_next = false
} else {
sb.write_char(c)
}
}
sb.to_string()
}
///|
fn repeat_str(s : String, n : Int) -> String {
let sb = StringBuilder::new()
for _i in 0..