use std::path::Path;
use serde_json::{Value, json};
use crate::exit;
pub struct Block {
pub name: &'static str,
pub usage: &'static str,
pub source: &'static str,
}
impl Block {
pub fn summary(&self) -> &str {
self.source
.lines()
.next()
.and_then(|l| l.strip_prefix("//! "))
.unwrap_or("")
}
}
pub const BLOCKS: &[Block] = &[
Block {
name: "logo_reveal",
usage: r#"blocks::logo_reveal(view, "oxframe", "Motion graphics as Rust code").await"#,
source: include_str!("../templates/blocks/logo_reveal.rs"),
},
Block {
name: "stat_counter",
usage: r#"blocks::stat_counter(view, 1280.0, "stars on GitHub").await"#,
source: include_str!("../templates/blocks/stat_counter.rs"),
},
Block {
name: "progress_bar",
usage: r#"blocks::progress_bar(view, "Tests passing", 0.92).await"#,
source: include_str!("../templates/blocks/progress_bar.rs"),
},
Block {
name: "quote_card",
usage: r#"blocks::quote_card(view, "Make it work, then make it fast.", "Kent Beck").await"#,
source: include_str!("../templates/blocks/quote_card.rs"),
},
Block {
name: "before_after",
usage: r#"blocks::before_after(view, "12 s to render", "1.2 s to render").await"#,
source: include_str!("../templates/blocks/before_after.rs"),
},
Block {
name: "timeline",
usage: r#"blocks::timeline(view, &[("2019", "First commit"), ("2026", "1.0")]).await"#,
source: include_str!("../templates/blocks/timeline.rs"),
},
Block {
name: "release_notes",
usage: r#"blocks::release_notes(view, "v0.23.0", &["Custom shaders run", "Effects inside groups"]).await"#,
source: include_str!("../templates/blocks/release_notes.rs"),
},
Block {
name: "terminal",
usage: r#"blocks::terminal(view, "cargo install oxframe-cli", &["Installed oxframe 0.23.0"]).await"#,
source: include_str!("../templates/blocks/terminal.rs"),
},
Block {
name: "code_change",
usage: r#"blocks::code_change(view, "rust", "let x = 1;", "let x = 2;").await"#,
source: include_str!("../templates/blocks/code_change.rs"),
},
Block {
name: "bar_chart",
usage: r#"blocks::bar_chart(view, "Build time, s", &[("rustc", 3.2), ("gcc", 7.5)]).await"#,
source: include_str!("../templates/blocks/bar_chart.rs"),
},
Block {
name: "call_to_action",
usage: r#"blocks::call_to_action(view, "Try it today", "oxframe.dev").await"#,
source: include_str!("../templates/blocks/call_to_action.rs"),
},
Block {
name: "kinetic_title",
usage: r#"blocks::kinetic_title(view, "Motion graphics your agent checks").await"#,
source: include_str!("../templates/blocks/kinetic_title.rs"),
},
Block {
name: "feature_grid",
usage: r#"blocks::feature_grid(view, &[("Fast", "Renders natively"), ("Checked", "Agents read the report")]).await"#,
source: include_str!("../templates/blocks/feature_grid.rs"),
},
Block {
name: "checklist",
usage: r#"blocks::checklist(view, "Before you ship", &["Tests pass", "Docs updated"]).await"#,
source: include_str!("../templates/blocks/checklist.rs"),
},
Block {
name: "comparison",
usage: r#"blocks::comparison(view, "oxframe", "Others", &[("Renders without a browser", true, false)]).await"#,
source: include_str!("../templates/blocks/comparison.rs"),
},
Block {
name: "chat_messages",
usage: r#"blocks::chat_messages(view, &[(false, "Can it render on CI?"), (true, "Yes, on the CPU")]).await"#,
source: include_str!("../templates/blocks/chat_messages.rs"),
},
Block {
name: "pull_request",
usage: r#"blocks::pull_request(view, 482, "Render on the GPU by default", 120, 38).await"#,
source: include_str!("../templates/blocks/pull_request.rs"),
},
Block {
name: "countdown",
usage: r#"blocks::countdown(view, 3, "Go!").await"#,
source: include_str!("../templates/blocks/countdown.rs"),
},
];
pub fn find(name: &str) -> Option<&'static Block> {
let name = name.replace('-', "_");
BLOCKS.iter().find(|b| b.name == name)
}
pub fn run(block: Option<String>, force: bool, json_mode: bool) -> crate::reply::Done {
let Some(name) = block else {
return catalog(json_mode);
};
let Some(block) = find(&name) else {
let names: Vec<_> = BLOCKS.iter().map(|b| b.name.replace('_', "-")).collect();
return crate::reply::fail(
json_mode,
exit::BUILD,
format!("no block `{name}`; the blocks are: {}", names.join(", ")),
);
};
let root = match crate::config::project_root() {
Ok(r) => r,
Err(e) => return crate::reply::fail(json_mode, exit::ENV, e),
};
match add(&root, block, force) {
Ok(wrote) => {
if !json_mode {
crate::note(format!("added src/blocks/{}.rs", block.name));
println!("Call it in a scene:\n {}", block.usage);
if !wrote.main {
println!(
"Add `mod blocks;` next to the other `mod` lines of your crate root to use it."
);
}
}
crate::reply::done(
json_mode,
exit::OK,
json!({ "block": block.name, "file": format!("src/blocks/{}.rs", block.name), "usage": block.usage }),
)
}
Err(e) => crate::reply::fail(json_mode, exit::ENV, e),
}
}
fn catalog(json_mode: bool) -> crate::reply::Done {
if json_mode {
let list: Vec<Value> = BLOCKS
.iter()
.map(|b| json!({ "name": b.name, "summary": b.summary(), "usage": b.usage }))
.collect();
return crate::reply::Done::with(exit::OK, json!({ "blocks": list }));
}
println!("Blocks for `oxframe add <name>`:\n");
for b in BLOCKS {
println!(" {:<14} {}", b.name.replace('_', "-"), b.summary());
println!(" {:<14} {}\n", "", b.usage);
}
crate::reply::Done::plain(exit::OK)
}
struct Wrote {
main: bool,
}
fn add(root: &Path, block: &Block, force: bool) -> anyhow::Result<Wrote> {
let dir = root.join("src/blocks");
std::fs::create_dir_all(&dir)?;
let file = dir.join(format!("{}.rs", block.name));
if file.exists() && !force {
anyhow::bail!(
"src/blocks/{}.rs exists; pass --force to replace it",
block.name
);
}
std::fs::write(&file, block.source)?;
let module = dir.join("mod.rs");
let current = std::fs::read_to_string(&module).unwrap_or_default();
std::fs::write(&module, register(¤t, block.name))?;
let main = root.join("src/main.rs");
let wrote_main = match std::fs::read_to_string(&main) {
Ok(text) => {
std::fs::write(&main, with_mod_blocks(&text))?;
true
}
Err(_) => false,
};
Ok(Wrote { main: wrote_main })
}
const ALLOW: &str = "#![allow(dead_code)]";
const MOD_HEADER: &str = "//! Blocks added with `oxframe add`. Each is an `async fn` that builds its\n//! nodes in a group, plays them in and returns the group.\n";
pub fn register(current: &str, name: &str) -> String {
let mut mods: Vec<String> = Vec::new();
let mut uses: Vec<String> = Vec::new();
let mut other: Vec<String> = Vec::new();
for line in current.lines() {
let t = line.trim();
if t.starts_with("pub mod ") {
mods.push(t.to_owned());
} else if t.starts_with("pub use ") {
uses.push(t.to_owned());
} else if !t.is_empty() && !t.starts_with("//!") && t != ALLOW {
other.push(line.to_owned());
}
}
mods.push(format!("pub mod {name};"));
uses.push(format!("pub use {name}::{name};"));
for list in [&mut mods, &mut uses] {
list.sort();
list.dedup();
}
let mut out = String::from(MOD_HEADER);
out.push_str(ALLOW);
out.push_str("\n\n");
for m in &mods {
out.push_str(m);
out.push('\n');
}
out.push('\n');
for u in &uses {
out.push_str(u);
out.push('\n');
}
if !other.is_empty() {
out.push('\n');
for o in &other {
out.push_str(o);
out.push('\n');
}
}
out
}
pub fn with_mod_blocks(main: &str) -> String {
if main
.lines()
.any(|l| matches!(l.trim(), "mod blocks;" | "pub mod blocks;"))
{
return main.to_owned();
}
let lines: Vec<&str> = main.lines().collect();
let mut at = 0;
for (i, line) in lines.iter().enumerate() {
let t = line.trim();
if t.starts_with("use ") || t.starts_with("//!") || t.starts_with("#![") {
at = i + 1;
} else if !t.is_empty() {
break;
}
}
let mut out: Vec<String> = lines.iter().map(|l| (*l).to_owned()).collect();
out.insert(at, String::new());
out.insert(at + 1, "mod blocks;".to_owned());
let mut text = out.join("\n");
if main.ends_with('\n') {
text.push('\n');
}
text
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_block_has_a_summary_and_matching_function() {
for b in BLOCKS {
assert!(!b.summary().is_empty(), "{}", b.name);
assert!(
b.source.contains(&format!("pub async fn {}(", b.name)),
"{}",
b.name
);
assert!(b.usage.starts_with(&format!("blocks::{}(", b.name)));
}
assert_eq!(find("quote-card").map(|b| b.name), Some("quote_card"));
assert!(find("nope").is_none());
}
#[test]
fn registering_keeps_mod_rs_sorted_and_unique() {
let one = register("", "timeline");
let two = register(&one, "quote_card");
let again = register(&two, "timeline");
assert_eq!(two, again);
assert_eq!(two.matches(ALLOW).count(), 1);
assert!(two.contains("pub mod quote_card;\npub mod timeline;\n"));
assert!(two.contains("pub use quote_card::quote_card;\npub use timeline::timeline;\n"));
}
#[test]
fn mod_blocks_goes_after_the_use_lines_once() {
let main = "use oxframe::prelude::*;\n\n#[oxframe::scene]\nasync fn intro(view: View) {}\n";
let once = with_mod_blocks(main);
assert_eq!(
once,
"use oxframe::prelude::*;\n\nmod blocks;\n\n#[oxframe::scene]\nasync fn intro(view: View) {}\n"
);
assert_eq!(with_mod_blocks(&once), once);
}
}