Add WebsiteService for static website generation and local preview

- Implemented WebsiteService to create and manage website projects from templates.
- Added functionality to start and stop a local HTTP server for project previews.
- Defined multiple built-in website templates (blank, landing page, portfolio, blog, documentation, dashboard) with corresponding HTML, CSS, and JS generation methods.
- Introduced WebsiteProject and WebsiteTemplate models to encapsulate project data and template details.
- Included error handling for project creation and template management.
This commit is contained in:
cx-git-agent
2026-04-21 19:10:12 -05:00
parent b9bbc5034d
commit 7e79fe89ca
10 changed files with 2107 additions and 0 deletions
+305
View File
@@ -0,0 +1,305 @@
// WebsiteDeployService.swift
// CxIDE Deploy websites to free hosting providers
//
// Supports: Netlify (drop), Surge.sh, GitHub Pages, Vercel,
// and generic SFTP/rsync deployment.
import Foundation
// MARK: - Website Deploy Service
final class WebsiteDeployService: @unchecked Sendable {
static let shared = WebsiteDeployService()
enum Provider: String, CaseIterable, Sendable {
case netlify = "Netlify"
case surge = "Surge.sh"
case githubPages = "GitHub Pages"
case vercel = "Vercel"
case rsync = "rsync/SFTP"
var description: String {
switch self {
case .netlify: return "Free hosting with custom domains, HTTPS, and CI/CD"
case .surge: return "Free static hosting with custom domains"
case .githubPages: return "Free hosting from a GitHub repository"
case .vercel: return "Free serverless hosting with edge network"
case .rsync: return "Deploy via rsync or SFTP to any server"
}
}
var requiresCLI: String? {
switch self {
case .netlify: return "netlify"
case .surge: return "surge"
case .vercel: return "vercel"
case .githubPages: return "git"
case .rsync: return "rsync"
}
}
}
struct DeployResult: Sendable {
let success: Bool
let url: String?
let output: String
let provider: Provider
let duration: TimeInterval
}
// MARK: - Deploy
/// Deploy a website directory to a hosting provider
func deploy(
directory: URL,
provider: Provider,
options: DeployOptions = DeployOptions()
) async -> DeployResult {
let start = Date()
// Verify the directory exists and has an index.html
let fm = FileManager.default
guard fm.fileExists(atPath: directory.path) else {
return DeployResult(success: false, url: nil, output: "Directory not found: \(directory.path)", provider: provider, duration: 0)
}
let hasIndex = fm.fileExists(atPath: directory.appendingPathComponent("index.html").path)
if !hasIndex {
return DeployResult(success: false, url: nil, output: "No index.html found in \(directory.path)", provider: provider, duration: 0)
}
let result: DeployResult
switch provider {
case .netlify:
result = await deployToNetlify(directory: directory, options: options, start: start)
case .surge:
result = await deployToSurge(directory: directory, options: options, start: start)
case .githubPages:
result = await deployToGitHubPages(directory: directory, options: options, start: start)
case .vercel:
result = await deployToVercel(directory: directory, options: options, start: start)
case .rsync:
result = await deployViaRsync(directory: directory, options: options, start: start)
}
return result
}
// MARK: - Check CLI availability
func checkCLI(for provider: Provider) -> Bool {
guard let cli = provider.requiresCLI else { return true }
let result = shell("which \(cli)")
return result.exitCode == 0
}
/// Install a CLI tool via npm (for netlify, surge, vercel)
func installCLI(for provider: Provider) async -> (success: Bool, output: String) {
switch provider {
case .netlify:
let r = shell("npm install -g netlify-cli"); return (r.exitCode == 0, r.output)
case .surge:
let r = shell("npm install -g surge"); return (r.exitCode == 0, r.output)
case .vercel:
let r = shell("npm install -g vercel"); return (r.exitCode == 0, r.output)
default:
return (true, "No installation needed for \(provider.rawValue)")
}
}
// MARK: - Provider Implementations
private func deployToNetlify(directory: URL, options: DeployOptions, start: Date) async -> DeployResult {
// Check if netlify CLI is available
guard checkCLI(for: .netlify) else {
return DeployResult(success: false, url: nil,
output: "Netlify CLI not found. Install with: npm install -g netlify-cli",
provider: .netlify, duration: Date().timeIntervalSince(start))
}
// Deploy with netlify deploy
var cmd = "cd \(shellEscape(directory.path)) && netlify deploy --dir=. --prod"
if let siteName = options.siteName {
cmd += " --site=\(siteName)"
}
let result = shell(cmd)
let duration = Date().timeIntervalSince(start)
// Extract URL from output
let url = extractURL(from: result.output, pattern: "https://.*\\.netlify\\.app")
return DeployResult(
success: result.exitCode == 0,
url: url,
output: result.output,
provider: .netlify,
duration: duration
)
}
private func deployToSurge(directory: URL, options: DeployOptions, start: Date) async -> DeployResult {
guard checkCLI(for: .surge) else {
return DeployResult(success: false, url: nil,
output: "Surge CLI not found. Install with: npm install -g surge",
provider: .surge, duration: Date().timeIntervalSince(start))
}
let domain = options.domain ?? "\(options.siteName ?? "cxide-site").surge.sh"
let cmd = "cd \(shellEscape(directory.path)) && surge . \(domain)"
let result = shell(cmd)
let duration = Date().timeIntervalSince(start)
return DeployResult(
success: result.exitCode == 0,
url: result.exitCode == 0 ? "https://\(domain)" : nil,
output: result.output,
provider: .surge,
duration: duration
)
}
private func deployToGitHubPages(directory: URL, options: DeployOptions, start: Date) async -> DeployResult {
guard checkCLI(for: .githubPages) else {
return DeployResult(success: false, url: nil,
output: "Git not found.",
provider: .githubPages, duration: Date().timeIntervalSince(start))
}
guard let repo = options.gitRepo else {
return DeployResult(success: false, url: nil,
output: "GitHub repository URL required (options.gitRepo)",
provider: .githubPages, duration: Date().timeIntervalSince(start))
}
let cmds = [
"cd \(shellEscape(directory.path))",
"git init",
"git checkout -b gh-pages",
"git add -A",
"git commit -m 'Deploy to GitHub Pages'",
"git remote add origin \(repo)",
"git push -f origin gh-pages",
].joined(separator: " && ")
let result = shell(cmds)
let duration = Date().timeIntervalSince(start)
// Extract username/repo from URL
let url: String?
if let username = options.gitUsername {
let repoName = URL(string: repo)?.lastPathComponent.replacingOccurrences(of: ".git", with: "") ?? ""
url = "https://\(username).github.io/\(repoName)"
} else {
url = nil
}
return DeployResult(
success: result.exitCode == 0,
url: url,
output: result.output,
provider: .githubPages,
duration: duration
)
}
private func deployToVercel(directory: URL, options: DeployOptions, start: Date) async -> DeployResult {
guard checkCLI(for: .vercel) else {
return DeployResult(success: false, url: nil,
output: "Vercel CLI not found. Install with: npm install -g vercel",
provider: .vercel, duration: Date().timeIntervalSince(start))
}
let cmd = "cd \(shellEscape(directory.path)) && vercel --prod --yes"
let result = shell(cmd)
let duration = Date().timeIntervalSince(start)
let url = extractURL(from: result.output, pattern: "https://.*\\.vercel\\.app")
return DeployResult(
success: result.exitCode == 0,
url: url,
output: result.output,
provider: .vercel,
duration: duration
)
}
private func deployViaRsync(directory: URL, options: DeployOptions, start: Date) async -> DeployResult {
guard let target = options.rsyncTarget else {
return DeployResult(success: false, url: nil,
output: "rsync target required (e.g., user@host:/var/www/html/)",
provider: .rsync, duration: Date().timeIntervalSince(start))
}
let cmd = "rsync -avz --delete \(shellEscape(directory.path))/ \(target)"
let result = shell(cmd)
let duration = Date().timeIntervalSince(start)
return DeployResult(
success: result.exitCode == 0,
url: options.domain.map { "https://\($0)" },
output: result.output,
provider: .rsync,
duration: duration
)
}
// MARK: - Helpers
private func shell(_ command: String) -> (output: String, exitCode: Int32) {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/bin/bash")
proc.arguments = ["-c", command]
let outPipe = Pipe()
let errPipe = Pipe()
proc.standardOutput = outPipe
proc.standardError = errPipe
do {
try proc.run()
proc.waitUntilExit()
let outData = outPipe.fileHandleForReading.readDataToEndOfFile()
let errData = errPipe.fileHandleForReading.readDataToEndOfFile()
let output = (String(data: outData, encoding: .utf8) ?? "")
+ (String(data: errData, encoding: .utf8) ?? "")
return (output.trimmingCharacters(in: .whitespacesAndNewlines), proc.terminationStatus)
} catch {
return ("Error: \(error.localizedDescription)", -1)
}
}
private func shellEscape(_ path: String) -> String {
"'" + path.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
private func extractURL(from text: String, pattern: String) -> String? {
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)),
let range = Range(match.range, in: text) else { return nil }
return String(text[range])
}
}
// MARK: - Deploy Options
struct DeployOptions: Sendable {
var siteName: String?
var domain: String?
var gitRepo: String?
var gitUsername: String?
var rsyncTarget: String?
var netlifyToken: String?
init(siteName: String? = nil, domain: String? = nil, gitRepo: String? = nil,
gitUsername: String? = nil, rsyncTarget: String? = nil, netlifyToken: String? = nil) {
self.siteName = siteName
self.domain = domain
self.gitRepo = gitRepo
self.gitUsername = gitUsername
self.rsyncTarget = rsyncTarget
self.netlifyToken = netlifyToken
}
}