use crate::{error::DeployError, report::Reporter};
use anyhow::anyhow;
use std::{
path::{Path, PathBuf},
process::Command,
};
pub struct BuildArtifact {
pub source_dir: PathBuf,
}
pub trait BuildStrategy {
fn build(&self, reporter: &dyn Reporter) -> Result<BuildArtifact, DeployError>;
}
pub struct ViteSpaBuild {
pub project_path: String,
pub output_dir: String,
pub package_manager: String,
}
impl BuildStrategy for ViteSpaBuild {
fn build(&self, reporter: &dyn Reporter) -> Result<BuildArtifact, DeployError> {
reporter.step_start(&format!(
"Building {} with {}…",
self.project_path, self.package_manager
));
let project_dir = Path::new(&self.project_path);
if !project_dir.exists() {
reporter.step_fail(&format!(
"Project path '{}' does not exist.",
self.project_path
));
return Err(DeployError::Other(anyhow!(
"Project path '{}' does not exist. Check the 'source' field in .smb/config.toml.",
self.project_path
)));
}
let status = Command::new(&self.package_manager)
.arg("build")
.current_dir(&self.project_path)
.status()
.map_err(|e| {
reporter.step_fail(&format!("Failed to spawn '{}': {e}", self.package_manager));
DeployError::Other(anyhow!("Failed to spawn '{}': {e}", self.package_manager))
})?;
if !status.success() {
reporter.step_fail("Build failed. See output above.");
return Err(DeployError::Other(anyhow!(
"'{} build' exited with status {status}",
self.package_manager
)));
}
reporter.step_done("Build complete.");
Ok(BuildArtifact {
source_dir: project_dir.join(&self.output_dir),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SvelteKitAdapter {
Static,
Other(String),
Missing,
}
impl SvelteKitAdapter {
pub fn from_package_json(contents: &str) -> Result<Option<Self>, serde_json::Error> {
let manifest: serde_json::Value = serde_json::from_str(contents)?;
let dependency_names: Vec<&str> = ["dependencies", "devDependencies"]
.iter()
.filter_map(|section| manifest.get(section).and_then(|value| value.as_object()))
.flat_map(|section| section.keys().map(String::as_str))
.collect();
if !dependency_names.contains(&"@sveltejs/kit") {
return Ok(None);
}
let adapters: Vec<&str> = dependency_names
.iter()
.copied()
.filter(|name| name.starts_with("@sveltejs/adapter-"))
.collect();
if adapters.contains(&"@sveltejs/adapter-static") {
return Ok(Some(SvelteKitAdapter::Static));
}
Ok(Some(match adapters.first() {
Some(name) => SvelteKitAdapter::Other((*name).to_string()),
None => SvelteKitAdapter::Missing,
}))
}
}
pub struct SvelteKitBuild {
pub project_path: String,
pub output_dir: String,
pub package_manager: String,
}
impl SvelteKitBuild {
fn fail(&self, reporter: &dyn Reporter, message: String) -> DeployError {
reporter.step_fail(&message);
DeployError::Other(anyhow!(message))
}
fn run(&self, reporter: &dyn Reporter, arguments: &[&str]) -> Result<(), DeployError> {
let display = format!("{} {}", self.package_manager, arguments.join(" "));
let status = Command::new(&self.package_manager)
.args(arguments)
.current_dir(&self.project_path)
.status()
.map_err(|error| {
self.fail(
reporter,
format!("Failed to spawn '{}': {error}", self.package_manager),
)
})?;
if !status.success() {
return Err(self.fail(reporter, format!("'{display}' exited with status {status}")));
}
Ok(())
}
}
impl BuildStrategy for SvelteKitBuild {
fn build(&self, reporter: &dyn Reporter) -> Result<BuildArtifact, DeployError> {
reporter.step_start(&format!(
"Building SvelteKit app {} with {}…",
self.project_path, self.package_manager
));
let project_dir = Path::new(&self.project_path);
if !project_dir.exists() {
return Err(self.fail(
reporter,
format!(
"Project path '{}' does not exist. Check the 'source' field in .smb/config.toml.",
self.project_path
),
));
}
let manifest_path = project_dir.join("package.json");
let manifest = std::fs::read_to_string(&manifest_path).map_err(|error| {
self.fail(
reporter,
format!("Could not read {}: {error}", manifest_path.display()),
)
})?;
let adapter = SvelteKitAdapter::from_package_json(&manifest).map_err(|error| {
self.fail(
reporter,
format!("Could not parse {}: {error}", manifest_path.display()),
)
})?;
match adapter {
Some(SvelteKitAdapter::Static) => {}
None => {
return Err(self.fail(
reporter,
format!(
"'{}' does not depend on @sveltejs/kit. For a plain Svelte + Vite app, use kind = \"vite-spa\".",
self.project_path
),
))
}
Some(SvelteKitAdapter::Missing) => {
return Err(self.fail(
reporter,
"No SvelteKit adapter found. Add @sveltejs/adapter-static and use it in svelte.config.js.".to_string(),
))
}
Some(SvelteKitAdapter::Other(name)) => {
return Err(self.fail(
reporter,
format!(
"{name} is not supported yet. smbCloud deploys SvelteKit with @sveltejs/adapter-static; switch svelte.config.js to it."
),
))
}
}
self.run(reporter, &["install"])?;
self.run(reporter, &["run", "build"])?;
let source_dir = project_dir.join(&self.output_dir);
if !has_html_entry(&source_dir) {
return Err(self.fail(
reporter,
format!(
"{} has no HTML entry point. Prerender at least one page, or set `fallback` in adapter-static for a single-page app.",
source_dir.display()
),
));
}
reporter.step_done("Build complete.");
Ok(BuildArtifact { source_dir })
}
}
fn has_html_entry(directory: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(directory) else {
return false;
};
entries.flatten().any(|entry| {
let path: PathBuf = entry.path();
path.is_file()
&& path
.extension()
.is_some_and(|extension| extension == "html")
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_static_adapter_in_dev_dependencies() {
let manifest =
r#"{"devDependencies":{"@sveltejs/kit":"^2","@sveltejs/adapter-static":"^3"}}"#;
assert_eq!(
SvelteKitAdapter::from_package_json(manifest).unwrap(),
Some(SvelteKitAdapter::Static)
);
}
#[test]
fn prefers_static_when_several_adapters_are_listed() {
let manifest = r#"{"dependencies":{"@sveltejs/adapter-node":"^5"},"devDependencies":{"@sveltejs/kit":"^2","@sveltejs/adapter-static":"^3"}}"#;
assert_eq!(
SvelteKitAdapter::from_package_json(manifest).unwrap(),
Some(SvelteKitAdapter::Static)
);
}
#[test]
fn reports_other_adapters_by_name() {
let manifest =
r#"{"devDependencies":{"@sveltejs/kit":"^2","@sveltejs/adapter-auto":"^3"}}"#;
assert_eq!(
SvelteKitAdapter::from_package_json(manifest).unwrap(),
Some(SvelteKitAdapter::Other(
"@sveltejs/adapter-auto".to_string()
))
);
}
#[test]
fn reports_missing_adapter() {
let manifest = r#"{"devDependencies":{"@sveltejs/kit":"^2"}}"#;
assert_eq!(
SvelteKitAdapter::from_package_json(manifest).unwrap(),
Some(SvelteKitAdapter::Missing)
);
}
#[test]
fn plain_svelte_is_not_sveltekit() {
let manifest = r#"{"devDependencies":{"svelte":"^5","vite":"^5"}}"#;
assert_eq!(SvelteKitAdapter::from_package_json(manifest).unwrap(), None);
}
#[test]
fn invalid_manifest_is_an_error() {
assert!(SvelteKitAdapter::from_package_json("{").is_err());
}
#[test]
fn html_entry_accepts_spa_fallback() {
let directory = tempfile::tempdir().unwrap();
std::fs::write(directory.path().join("200.html"), "<html></html>").unwrap();
assert!(has_html_entry(directory.path()));
}
#[test]
fn html_entry_rejects_missing_or_asset_only_output() {
let directory = tempfile::tempdir().unwrap();
assert!(!has_html_entry(&directory.path().join("build")));
std::fs::create_dir(directory.path().join("_app")).unwrap();
std::fs::write(directory.path().join("robots.txt"), "").unwrap();
assert!(!has_html_entry(directory.path()));
}
}