use std::fs;
use std::path::PathBuf;
use std::time::Instant;
use crepuscularity_core::context::TemplateContext;
use crepuscularity_native::{render_template_to_ir, to_json_pretty, ViewIr, ViewNode};
use crate::cli::WebEmitTarget;
use crate::ui;
use super::build::{load_all_crepus_public, resolve_emit_paths};
pub(crate) fn build_emit_stub(
emit: WebEmitTarget,
site: Option<PathBuf>,
out_dir: Option<PathBuf>,
entry: Option<String>,
) {
let t0 = Instant::now();
let paths = resolve_emit_paths(site, out_dir, entry);
let mut files = std::collections::HashMap::new();
load_all_crepus_public(&paths.site_dir, &paths.site_dir, &mut files);
if files.is_empty() {
ui::error(&format!(
"no .crepus files under {}",
paths.site_dir.display()
));
}
let source = files.get(&paths.entry).cloned().unwrap_or_else(|| {
ui::error(&format!(
"entry {:?} not found under {}",
paths.entry,
paths.site_dir.display()
))
});
let ctx = TemplateContext::new();
let ir = render_template_to_ir(&source, &ctx).unwrap_or_else(|e| {
ui::error(&format!("lower {} to View IR: {e}", paths.entry));
});
fs::create_dir_all(&paths.out_dir).unwrap_or_else(|e| {
ui::error(&format!("mkdir {}: {e}", paths.out_dir.display()));
});
let ir_json = to_json_pretty(&ir).unwrap_or_else(|e| {
ui::error(&format!("serialize View IR: {e}"));
});
let ir_path = paths.out_dir.join("crepus-view-ir.json");
fs::write(&ir_path, &ir_json).unwrap_or_else(|e| {
ui::error(&format!("write {}: {e}", ir_path.display()));
});
let (filename, body) = match emit {
WebEmitTarget::Html => unreachable!("html uses WASM build path"),
WebEmitTarget::Moonshine => ("crepus-emit.moonshine.tsx", emit_moonshine(&ir)),
};
let out_file = paths.out_dir.join(filename);
fs::write(&out_file, body).unwrap_or_else(|e| {
ui::error(&format!("write {}: {e}", out_file.display()));
});
eprintln!(
"\n{} wrote {} (+ {})",
ui::ok(),
out_file.display(),
ir_path.display()
);
eprintln!(
" {} Moonshine emit: App() → JSX with className; install via `crepus moonshine dep`",
ui::dim("→")
);
ui::done_in(t0.elapsed());
}
fn emit_moonshine(ir: &ViewIr) -> String {
let children: String = ir
.root
.iter()
.map(|n| emit_jsx_node(n, 3))
.collect::<Vec<_>>()
.join("\n");
let mut body = format!(
r#"// Generated by crepus web build --emit moonshine
import {{ createApp }} from "@tschk/moonshine/react";
export function App() {{
return (
<div data-crepus-root="true">
{children}
</div>
);
}}
"#
);
body.push_str(
r##"
// Optional mount helper for Vite entry
export function mount(selector = "#app") {
createApp({ root: App }).mount(selector);
}
export default App;
"##,
);
body
}
fn js_str(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
fn class_attr(style: Option<&crepuscularity_native::ViewStyle>) -> String {
let classes = match style {
Some(s) if !s.classes.is_empty() => s.classes.join(" "),
_ => return String::new(),
};
format!(" className={{{}}}", js_str(&classes))
}
fn opt_attr(name: &str, value: Option<&String>) -> String {
match value {
Some(v) => format!(" {name}={{{}}}", js_str(v)),
None => String::new(),
}
}
fn emit_children(children: &[ViewNode], indent: usize) -> String {
children
.iter()
.map(|c| emit_jsx_node(c, indent))
.collect::<Vec<_>>()
.join("\n")
}
fn element(tag: &str, attrs: &str, children: &[ViewNode], indent: usize) -> String {
let pad = " ".repeat(indent);
if children.is_empty() {
return format!("{pad}<{tag}{attrs} />");
}
let inner = emit_children(children, indent + 1);
format!("{pad}<{tag}{attrs}>\n{inner}\n{pad}</{tag}>")
}
fn emit_jsx_node(node: &ViewNode, indent: usize) -> String {
let pad = " ".repeat(indent);
match node {
ViewNode::Text { content, style, .. } => {
format!(
"{pad}<span{}>{{{}}}</span>",
class_attr(style.as_ref()),
js_str(content)
)
}
ViewNode::Link {
href,
target,
rel,
style,
children,
} => {
let attrs = format!(
"{}{}{}",
format_args!(" href={{{}}}", js_str(href)),
opt_attr("target", target.as_ref()),
opt_attr("rel", rel.as_ref()),
);
element("a", &format!("{attrs}{}", class_attr(style.as_ref())), children, indent)
}
ViewNode::Stack { style, children, .. } => {
element("div", &class_attr(style.as_ref()), children, indent)
}
ViewNode::Scroll { style, children, .. } => {
element("div", &class_attr(style.as_ref()), children, indent)
}
ViewNode::Dropzone { style, children, .. } => {
element("div", &class_attr(style.as_ref()), children, indent)
}
ViewNode::List {
ordered,
style,
children,
} => element(
if *ordered { "ol" } else { "ul" },
&class_attr(style.as_ref()),
children,
indent,
),
ViewNode::ListItem { style, children, .. } => {
element("li", &class_attr(style.as_ref()), children, indent)
}
ViewNode::Button { label, style, .. } => format!(
"{pad}<button type=\"button\"{}>{{{}}}</button>",
class_attr(style.as_ref()),
js_str(label)
),
ViewNode::Badge { label, tone, style, .. } => format!(
"{pad}<span{}{}>{{{}}}</span>",
class_attr(style.as_ref()),
opt_attr("data-tone", tone.as_ref()),
js_str(label)
),
ViewNode::Divider { style, .. } => {
format!("{pad}<hr{} />", class_attr(style.as_ref()))
}
ViewNode::Spacer { style, .. } => {
format!("{pad}<div aria-hidden=\"true\"{} />", class_attr(style.as_ref()))
}
ViewNode::Image {
src, alt, style, ..
} => format!(
"{pad}<img src={{{}}} alt={{{}}}{} />",
js_str(src),
js_str(alt.as_deref().unwrap_or("")),
class_attr(style.as_ref())
),
ViewNode::WebView { src, style } => format!(
"{pad}<iframe src={{{}}}{} />",
js_str(src),
class_attr(style.as_ref())
),
ViewNode::Toggle {
label,
checked,
style,
..
} => format!(
"{pad}<button type=\"button\" role=\"switch\" aria-checked={{{}}}{}>{{{}}}</button>",
checked,
class_attr(style.as_ref()),
js_str(label)
),
ViewNode::Checkbox {
label,
checked,
style,
..
} => format!(
"{pad}<label{}>\n{pad} <input type=\"checkbox\" defaultChecked={{{}}} />\n{pad} {{{}}}\n{pad}</label>",
class_attr(style.as_ref()),
checked,
js_str(label)
),
ViewNode::Slider {
value,
min,
max,
step,
style,
..
} => format!(
"{pad}<input type=\"range\" defaultValue={{{value}}} min={{{min}}} max={{{max}}}{}{} />",
step.map(|s| format!(" step={{{s}}}")).unwrap_or_default(),
class_attr(style.as_ref())
),
ViewNode::Progress {
value, max, style, ..
} => format!(
"{pad}<progress value={{{value}}} max={{{max}}}{} />",
class_attr(style.as_ref())
),
ViewNode::Meter {
value,
min,
max,
style,
..
} => format!(
"{pad}<meter value={{{value}}} min={{{min}}} max={{{max}}}{} />",
class_attr(style.as_ref())
),
ViewNode::Input {
placeholder,
bind,
secure,
multiline,
style,
..
} => {
let cls = class_attr(style.as_ref());
let ph = format!(" placeholder={{{}}}", js_str(placeholder));
let name = format!(" name={{{}}}", js_str(bind));
if *multiline {
format!("{pad}<textarea{ph}{name}{cls} />")
} else {
let ty = if *secure { "password" } else { "text" };
format!("{pad}<input type=\"{ty}\"{ph}{name}{cls} />")
}
}
ViewNode::Picker {
options,
bind,
style,
..
} => {
let opts: String = options
.iter()
.map(|o| {
format!(
"{pad} <option value={{{}}}>{{{}}}</option>",
js_str(&o.value),
js_str(&o.label)
)
})
.collect::<Vec<_>>()
.join("\n");
format!(
"{pad}<select name={{{}}}{}>\n{opts}\n{pad}</select>",
js_str(bind),
class_attr(style.as_ref())
)
}
ViewNode::FilePicker { label, style, .. } => format!(
"{pad}<label{}>\n{pad} <input type=\"file\" />\n{pad} {{{}}}\n{pad}</label>",
class_attr(style.as_ref()),
js_str(label)
),
ViewNode::SlotRotate {
phrases,
interval_ms,
style,
} => format!(
"{pad}<span data-crepus-slot-rotate={{{}}} data-interval-ms={{{interval_ms}}}{}>{{{}}}</span>",
js_str(&phrases.join("|")),
class_attr(style.as_ref()),
js_str(phrases.first().map(String::as_str).unwrap_or(""))
),
ViewNode::Tabs { tabs, style, .. } => {
let cls = class_attr(style.as_ref());
let buttons: String = tabs
.iter()
.map(|t| {
format!(
"{pad} <button type=\"button\" role=\"tab\">{{{}}}</button>",
js_str(&t.label)
)
})
.collect::<Vec<_>>()
.join("\n");
let panels: String = tabs
.iter()
.map(|t| {
format!(
"{pad} <div role=\"tabpanel\">\n{}\n{pad} </div>",
emit_children(&t.children, indent + 2)
)
})
.collect::<Vec<_>>()
.join("\n");
format!(
"{pad}<div{cls}>\n{pad} <div role=\"tablist\">\n{buttons}\n{pad} </div>\n{panels}\n{pad}</div>"
)
}
ViewNode::If {
condition,
then_children,
style,
..
} => {
let attrs = format!(
" data-crepus-if={{{}}}{}",
js_str(condition),
class_attr(style.as_ref())
);
element("div", &attrs, then_children, indent)
}
ViewNode::ForEach {
bind,
item_name,
item_body,
style,
} => {
let attrs = format!(
" data-crepus-for-each={{{}}} data-crepus-item={{{}}}{}",
js_str(bind),
js_str(item_name),
class_attr(style.as_ref())
);
element("div", &attrs, item_body, indent)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_ir() -> ViewIr {
let source = r#"stack col gap-2
text "hi"
button "Go"
"#;
render_template_to_ir(source, &TemplateContext::new()).expect("ir")
}
#[test]
fn moonshine_emit_writes_real_jsx_with_classnames() {
let body = emit_moonshine(&sample_ir());
assert!(body.contains("@tschk/moonshine/react"));
assert!(body.contains("createApp"));
assert!(body.contains("export function App"));
assert!(body.contains("export function mount"));
assert!(body.contains("data-crepus-root=\"true\""));
assert!(body.contains("<div className={\"col gap-2\"}>"));
assert!(body.contains("<span>{\"hi\"}</span>"));
assert!(body.contains("<button type=\"button\">{\"Go\"}</button>"));
assert!(!body.contains("renderCrepusIr"));
assert!(!body.contains("satisfies ViewIr"));
assert!(!body.contains("TODO"));
}
#[test]
fn moonshine_emit_preserves_anchor_href_and_classes() {
let source =
"a href=\"https://example.com\" no-underline text-zinc-100\n span \"crepuscularity\"\n";
let ir = render_template_to_ir(source, &TemplateContext::new()).expect("ir");
let body = emit_moonshine(&ir);
assert!(body.contains("<a href={\"https://example.com\"}"));
assert!(body.contains("className={\"no-underline text-zinc-100\"}"));
assert!(body.contains("{\"crepuscularity\"}"));
}
}