diff --git a/crates/cli/build.rs b/crates/cli/build.rs index 1754c3f4c99..6b4aca48fbf 100644 --- a/crates/cli/build.rs +++ b/crates/cli/build.rs @@ -196,20 +196,29 @@ fn generate_template_files() { write_if_changed(&dest_path, generated_code.as_bytes()).expect("Failed to write embedded_templates.rs"); } -#[derive(Debug)] +#[derive(Debug, serde::Serialize)] struct TemplateInfo { id: String, description: String, + #[serde(serialize_with = "serialize_option_string_as_empty")] server_source: Option, + #[serde(serialize_with = "serialize_option_string_as_empty")] client_source: Option, server_lang: Option, client_lang: Option, + client_framework: Option, +} + +#[derive(serde::Serialize)] +struct TemplatesJson<'a> { + templates: &'a [TemplateInfo], } #[derive(serde::Deserialize)] struct TemplateMetadata { description: String, client_lang: Option, + client_framework: Option, server_lang: Option, } @@ -267,6 +276,7 @@ fn discover_templates(templates_dir: &Path) -> Vec { client_source, server_lang: metadata.server_lang, client_lang: metadata.client_lang, + client_framework: metadata.client_framework, }); } } @@ -276,57 +286,15 @@ fn discover_templates(templates_dir: &Path) -> Vec { } fn generate_templates_json(templates: &[TemplateInfo]) -> String { - let mut json = String::from("{\n \"highlights\": [\n"); - - for template in templates { - if template.id.contains("react") { - json.push_str(" { \"name\": \"React\", \"template_id\": \""); - json.push_str(&template.id); - json.push_str("\" }\n"); - break; - } - } - - json.push_str(" ],\n \"templates\": [\n"); - - for (i, template) in templates.iter().enumerate() { - json.push_str(" {\n"); - json.push_str(&format!(" \"id\": \"{}\",\n", template.id)); - json.push_str(&format!(" \"description\": \"{}\",\n", template.description)); - - if let Some(ref server_source) = template.server_source { - json.push_str(&format!(" \"server_source\": \"{}\",\n", server_source)); - } else { - json.push_str(" \"server_source\": \"\",\n"); - } - - if let Some(ref client_source) = template.client_source { - json.push_str(&format!(" \"client_source\": \"{}\",\n", client_source)); - } else { - json.push_str(" \"client_source\": \"\",\n"); - } - - if let Some(ref server_lang) = template.server_lang { - json.push_str(&format!(" \"server_lang\": \"{}\",\n", server_lang)); - } else { - json.push_str(" \"server_lang\": \"\",\n"); - } - - if let Some(ref client_lang) = template.client_lang { - json.push_str(&format!(" \"client_lang\": \"{}\"", client_lang)); - } else { - json.push_str(" \"client_lang\": \"\""); - } - - json.push_str("\n }"); - if i < templates.len() - 1 { - json.push(','); - } - json.push('\n'); - } + let payload = TemplatesJson { templates }; + serde_json::to_string_pretty(&payload).expect("Failed to serialize templates JSON") +} - json.push_str(" ]\n}"); - json +fn serialize_option_string_as_empty(value: &Option, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_str(value.as_deref().unwrap_or("")) } fn generate_template_entry(code: &mut String, template_path: &Path, source: &str, manifest_dir: &Path) { diff --git a/crates/cli/src/subcommands/init.rs b/crates/cli/src/subcommands/init.rs index 58d567ef85d..a873ee2324f 100644 --- a/crates/cli/src/subcommands/init.rs +++ b/crates/cli/src/subcommands/init.rs @@ -5,12 +5,13 @@ use anyhow::Context; use clap::{Arg, ArgMatches}; use colored::Colorize; use convert_case::{Case, Casing}; -use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select}; +use dialoguer::{theme::ColorfulTheme, Confirm, FuzzySelect, Input, Select}; use reqwest::Url; use serde::{Deserialize, Serialize}; use serde_json::json; use spacetimedb_client_api_messages::name::parse_database_name; use spacetimedb_data_structures::map::{HashCollectionExt as _, HashMap}; +use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -35,17 +36,12 @@ pub struct TemplateDefinition { pub server_lang: Option, #[serde(default)] pub client_lang: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct HighlightDefinition { - pub name: String, - pub template_id: String, + #[serde(default)] + pub client_framework: Option, } #[derive(Debug, Deserialize)] struct TemplatesList { - highlights: Vec, templates: Vec, } @@ -195,11 +191,11 @@ pub fn cli() -> clap::Command { ) } -pub async fn fetch_templates_list() -> anyhow::Result<(Vec, Vec)> { +pub async fn fetch_templates_list() -> anyhow::Result> { let content = embedded::get_templates_json(); let templates_list: TemplatesList = serde_json::from_str(content).context("Failed to parse templates list JSON")?; - Ok((templates_list.highlights, templates_list.templates)) + Ok(templates_list.templates) } pub async fn check_and_prompt_login(config: &mut Config) -> anyhow::Result { @@ -680,7 +676,7 @@ async fn get_template_config_non_interactive( // Check if template is provided if let Some(template_str) = options.template.as_ref() { // Check if it's a builtin template - let (_, templates) = fetch_templates_list().await?; + let templates = fetch_templates_list().await?; return create_template_config_from_template_str(project_name, project_path, template_str, &templates); } @@ -745,7 +741,7 @@ async fn get_template_config_interactive( if let Some(template_str) = options.template.as_ref() { println!("{} {}", "Template:".bold(), template_str); - let (_, templates) = fetch_templates_list().await?; + let templates = fetch_templates_list().await?; return create_template_config_from_template_str(project_name, project_path, template_str, &templates); } @@ -769,36 +765,72 @@ async fn get_template_config_interactive( } // Fully interactive mode - prompt for template/language selection - let (highlights, templates) = fetch_templates_list().await?; + let templates = fetch_templates_list().await?; + + // First menu: all language combinations from templates + GitHub + None. + let mut templates_by_lang: BTreeMap> = BTreeMap::new(); + for (idx, template) in templates.iter().enumerate() { + let server_lang = template.server_lang.as_deref(); + let client_lang = template.client_framework.as_deref().or(template.client_lang.as_deref()); + let lang = if server_lang == client_lang { + format_language_label(server_lang) + } else { + format!( + "{}/{}", + format_language_label(server_lang), + format_language_label(client_lang), + ) + }; + templates_by_lang.entry(lang).or_default().push(idx); + } - let mut client_choices: Vec = highlights + let lang_keys: Vec = templates_by_lang.keys().cloned().collect(); + let mut lang_items: Vec = templates_by_lang .iter() - .map(|h| { - let template = templates.iter().find(|t| t.id == h.template_id); - match template { - Some(t) => format!("{} - {}", h.name, t.description), - None => h.name.clone(), - } + .map(|(lang, template_indices)| { + let count = template_indices.len(); + let template_word = if count == 1 { "template" } else { "templates" }; + format!("{lang} ({count} {template_word})") }) .collect(); - client_choices.push("Use Template - Choose from a list of built-in template projects or clone an existing SpacetimeDB project from GitHub".to_string()); - client_choices.push("None".to_string()); + lang_items.push("Clone from GitHub (owner/repo or git URL)".to_string()); + lang_items.push("None".to_string()); - let client_selection = Select::with_theme(&theme) - .with_prompt("Select a client type for your project (you can add other clients later)") - .items(&client_choices) + let client_selection = FuzzySelect::with_theme(&theme) + .with_prompt("Select a language (type to filter)") + .items(&lang_items) .default(0) .interact()?; - let other_index = highlights.len(); - let none_index = highlights.len() + 1; - - if client_selection < highlights.len() { - let highlight = &highlights[client_selection]; - let template = templates - .iter() - .find(|t| t.id == highlight.template_id) - .ok_or_else(|| anyhow::anyhow!("Template {} not found", highlight.template_id))?; + let github_clone_index = lang_keys.len(); + let none_index = lang_keys.len() + 1; + + if client_selection < lang_keys.len() { + let selected_lang = &lang_keys[client_selection]; + let template_indices = templates_by_lang + .get(selected_lang) + .ok_or_else(|| anyhow::anyhow!("No templates found for selected language"))?; + + let template = if template_indices.len() > 1 { + let template_items: Vec = template_indices + .iter() + .map(|&idx| { + let template = &templates[idx]; + format!("{} - {}", template.id, template.description) + }) + .collect(); + + let pair_prompt = format!("Templates available for {selected_lang} (type to filter)"); + let template_selection = FuzzySelect::with_theme(&theme) + .with_prompt(&pair_prompt) + .items(&template_items) + .default(0) + .interact()?; + + &templates[template_indices[template_selection]] + } else { + &templates[template_indices[0]] + }; Ok(TemplateConfig { project_name, @@ -810,44 +842,23 @@ async fn get_template_config_interactive( template_def: Some(template.clone()), use_local: true, }) - } else if client_selection == other_index { - println!("\n{}", "Available built-in templates:".bold()); - for template in &templates { - println!(" {} - {}", template.id, template.description); - } - println!(); - + } else if client_selection == github_clone_index { loop { - let template_id = Input::::with_theme(&theme) - .with_prompt("Template ID or GitHub repository (owner/repo) or git URL") + let repo_input = Input::::with_theme(&theme) + .with_prompt("GitHub repository (owner/repo) or git URL") .interact_text()? .trim() .to_string(); - let template_config = create_template_config_from_template_str( + if repo_input.is_empty() { + eprintln!("{}", "Please enter a GitHub repository.".bold()); + continue; + } + break create_template_config_from_template_str( project_name.clone(), project_path.clone(), - &template_id, + &repo_input, &templates, ); - // If template_id looks like a builtin template ID (e.g. kebab-case, all lowercase, no slashes, alphanumeric and dashes only) - // then ensure that it is a valid builtin template ID, if not reprompt - let is_builtin_like = |s: &str| { - !s.is_empty() - && !s.contains('/') - && s.chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') - }; - if !is_builtin_like(&template_id) { - break template_config; - } - if templates.iter().any(|t| t.id == template_id) { - break template_config; - } - eprintln!( - "{}", - "Unrecognized format. Enter a built-in ID (e.g. \"rust-chat\"), a GitHub repo (\"owner/repo\"), or a git URL." - .bold() - ); } } else if client_selection == none_index { // Ask for server language only @@ -880,6 +891,17 @@ async fn get_template_config_interactive( } } +fn format_language_label(lang: Option<&str>) -> String { + match lang { + Some(value) if value.eq_ignore_ascii_case("rust") => "Rust".to_string(), + Some(value) if value.eq_ignore_ascii_case("csharp") || value.eq_ignore_ascii_case("c#") => "C#".to_string(), + Some(value) if value.eq_ignore_ascii_case("typescript") => "TypeScript".to_string(), + Some(value) if value.eq_ignore_ascii_case("cpp") || value.eq_ignore_ascii_case("c++") => "C++".to_string(), + Some(value) if !value.trim().is_empty() => value.to_string(), + _ => "None".to_string(), + } +} + fn clone_github_template(repo_input: &str, target: &Path, is_server_only: bool) -> anyhow::Result<()> { let is_git_url = |s: &str| { s.starts_with("git@") || s.starts_with("ssh://") || s.starts_with("http://") || s.starts_with("https://") diff --git a/templates/angular-ts/.template.json b/templates/angular-ts/.template.json index afc15dbf0e5..06c3ea9a227 100644 --- a/templates/angular-ts/.template.json +++ b/templates/angular-ts/.template.json @@ -1,5 +1,6 @@ { "description": "Angular web app with TypeScript server", + "client_framework": "Angular", "client_lang": "typescript", "server_lang": "typescript" } diff --git a/templates/bun-ts/.template.json b/templates/bun-ts/.template.json index f6a2ab3b67e..2d523dc53b8 100644 --- a/templates/bun-ts/.template.json +++ b/templates/bun-ts/.template.json @@ -1,5 +1,6 @@ { "description": "Bun TypeScript client and server template", + "client_framework": "Bun", "client_lang": "typescript", "server_lang": "typescript" } diff --git a/templates/nextjs-ts/.template.json b/templates/nextjs-ts/.template.json index 19b5fb62be2..85589e54e1c 100644 --- a/templates/nextjs-ts/.template.json +++ b/templates/nextjs-ts/.template.json @@ -1,5 +1,6 @@ { "description": "Next.js App Router with TypeScript server", + "client_framework": "Next.js", "client_lang": "typescript", "server_lang": "typescript" } diff --git a/templates/nodejs-ts/.template.json b/templates/nodejs-ts/.template.json index af38af2c866..bb21899d82f 100644 --- a/templates/nodejs-ts/.template.json +++ b/templates/nodejs-ts/.template.json @@ -1,5 +1,6 @@ { "description": "Node.js TypeScript client and server template", + "client_framework": "Node.js", "client_lang": "typescript", "server_lang": "typescript" } diff --git a/templates/nuxt-ts/.template.json b/templates/nuxt-ts/.template.json index 812ba61c574..ea34667b01e 100644 --- a/templates/nuxt-ts/.template.json +++ b/templates/nuxt-ts/.template.json @@ -1,5 +1,6 @@ { "description": "Nuxt web app with TypeScript server", + "client_framework": "Nuxt", "client_lang": "typescript", "server_lang": "typescript" } diff --git a/templates/react-ts/.template.json b/templates/react-ts/.template.json index a11db218e11..6dc635a7ccf 100644 --- a/templates/react-ts/.template.json +++ b/templates/react-ts/.template.json @@ -1,5 +1,6 @@ { "description": "React web app with TypeScript server", + "client_framework": "React", "client_lang": "typescript", "server_lang": "typescript" } diff --git a/templates/remix-ts/.template.json b/templates/remix-ts/.template.json index 924245a1bc9..6a2c68072c3 100644 --- a/templates/remix-ts/.template.json +++ b/templates/remix-ts/.template.json @@ -1,5 +1,6 @@ { "description": "Remix with TypeScript server", + "client_framework": "Remix", "client_lang": "typescript", "server_lang": "typescript" } diff --git a/templates/svelte-ts/.template.json b/templates/svelte-ts/.template.json index 2f7d4081e89..31bbd725c6a 100644 --- a/templates/svelte-ts/.template.json +++ b/templates/svelte-ts/.template.json @@ -1,5 +1,6 @@ { "description": "Svelte web app with TypeScript server", + "client_framework": "Svelte", "client_lang": "typescript", "server_lang": "typescript" } diff --git a/templates/tanstack-ts/.template.json b/templates/tanstack-ts/.template.json index d8cc9052acb..76ab7261836 100644 --- a/templates/tanstack-ts/.template.json +++ b/templates/tanstack-ts/.template.json @@ -1,5 +1,6 @@ { "description": "TanStack Start (React + TanStack Query/Router) with TypeScript server", + "client_framework": "TanStack", "client_lang": "typescript", "server_lang": "typescript" } diff --git a/templates/vue-ts/.template.json b/templates/vue-ts/.template.json index ea14a0674ad..e66e510a0b0 100644 --- a/templates/vue-ts/.template.json +++ b/templates/vue-ts/.template.json @@ -1,5 +1,6 @@ { "description": "Vue.js web app with TypeScript server", + "client_framework": "Vue.js", "client_lang": "typescript", "server_lang": "typescript" }