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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user