From 084523270e417d6ad309b0e04aaf3b7c5eec7f89 Mon Sep 17 00:00:00 2001 From: Mac Long Date: Thu, 8 May 2025 16:05:19 +0200 Subject: [PATCH 01/10] fix: remove title from README for readability --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 8dd52712..97c86044 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ -# WebUI -

Logo From ce7b64793ef575dc7586c3ce806a91d0f6212fb1 Mon Sep 17 00:00:00 2001 From: Mac Date: Thu, 8 May 2025 16:48:45 +0200 Subject: [PATCH 02/10] fix: improve metadata handling --- Sources/WebUI/Core/Document.swift | 70 ++++++------------------ Sources/WebUI/Core/Metadata.swift | 88 ++++++++++++++----------------- 2 files changed, 57 insertions(+), 101 deletions(-) diff --git a/Sources/WebUI/Core/Document.swift b/Sources/WebUI/Core/Document.swift index acfd6023..72ac39f2 100644 --- a/Sources/WebUI/Core/Document.swift +++ b/Sources/WebUI/Core/Document.swift @@ -8,6 +8,11 @@ public enum ScriptAttribute: String { case async } +public struct Script { + let src: String + let attribute: ScriptAttribute? +} + /// Represents an immutable HTML document with metadata and content. public struct Document { private let logger = Logger(label: "com.webui.document") @@ -69,42 +74,11 @@ public struct Document { logger.debug("Rendering document: \(path ?? "index")") logger.trace("Starting metadata rendering") - var optionalMetaTags: [String] = [] - if let image = metadata.image, !image.isEmpty { - logger.trace("Adding og:image meta tag: \(image)") - optionalMetaTags.append( - "" - ) - } - if let author = metadata.author, !author.isEmpty { - logger.trace("Adding author meta tag: \(author)") - optionalMetaTags.append("") - } - if let twitter = metadata.twitter, !twitter.isEmpty { - logger.trace("Adding twitter meta tag: \(twitter)") - optionalMetaTags.append( - "" - ) - } - if let keywords = metadata.keywords, !keywords.isEmpty { - logger.trace("Adding keywords meta tag with \(keywords.count) keywords") - optionalMetaTags.append( - "" - ) - } - if let themeColor = metadata.themeColor { - logger.trace("Adding theme-color meta tags") - optionalMetaTags.append( - "" - ) - optionalMetaTags.append( - "" - ) - } + var optionalTags: [String] = metadata.tags + [] if let scripts = scripts { logger.trace("Adding \(scripts.count) script tags") for script in scripts { - optionalMetaTags.append( + optionalTags.append( "" ) } @@ -112,24 +86,22 @@ public struct Document { if let stylesheets = stylesheets { logger.trace("Adding \(stylesheets.count) stylesheet links") for stylesheet in stylesheets { - optionalMetaTags.append( + optionalTags.append( "" ) } } - logger.trace("Building head section") - let headSection = """ + logger.debug("Document rendered successfully: \(metadata.pageTitle)") + + return """ + + \(metadata.pageTitle) - - - - "" - - \(optionalMetaTags.joined(separator: "\n")) + \(optionalTags.joined(separator: "\n")) \(head ?? "") - - """ - - logger.trace("Rendering content elements") - let contentElements = content.map { $0.render() }.joined() - logger.debug("Document rendered successfully: \(metadata.pageTitle)") - - return """ - - - \(headSection) + - \(contentElements) + \(content.map { $0.render() }.joined()) """ diff --git a/Sources/WebUI/Core/Metadata.swift b/Sources/WebUI/Core/Metadata.swift index 5ae97f98..f2a7fd16 100644 --- a/Sources/WebUI/Core/Metadata.swift +++ b/Sources/WebUI/Core/Metadata.swift @@ -1,37 +1,28 @@ +// Metadata.swift import Foundation -/// Defines supported Open Graph content types for a document. public enum ContentType: String { case website, article, video, profile } -/// Defines supported language locales for a document. public enum Locale: String { case en, sp, fr, de, ja, ru } -/// Represents theme colors for light and dark modes. public struct ThemeColor { public let light: String public let dark: String - /// Creates a theme color with light and dark mode values. - /// - /// - Parameters: - /// - light: Hex color code for light mode (e.g., "#FFFFFF"). - /// - dark: Hex color code for dark mode (e.g., "#000000"). public init(light: String, dark: String) { self.light = light self.dark = dark } } -/// Stores metadata configuration for a document’s head section. public struct Metadata { public var site: String? public var title: String? public var titleSeperator: String - public var pageTitle: String public var description: String public var date: Date? public var image: String? @@ -42,24 +33,14 @@ public struct Metadata { public var type: ContentType public var themeColor: ThemeColor? - /// Creates metadata for a document’s head section. - /// - /// - Parameters: - /// - site: Website name used in title and branding, optional. - /// - title: Base title template for the page. - /// - titleSeperator: Separator between title and site, defaults to "|". - /// - description: Brief summary of page content for meta tags. - /// - image: Displayed when the url is shared on social media. - /// - author: Author name, optional. - /// - keywords: SEO keywords, optional. - /// - twitter: Twitter handle without "@", optional. - /// - locale: Language setting, defaults to `.en`. - /// - type: Open Graph content type, defaults to `.website`. - /// - themeColor: Theme colors for light and dark modes, optional. + public var pageTitle: String { + "\(title ?? "")\(titleSeperator)\(site ?? "")" + } + public init( site: String? = nil, title: String? = nil, - titleSeperator: String = "|", + titleSeperator: String = " | ", description: String, date: Date? = nil, image: String? = nil, @@ -73,8 +54,6 @@ public struct Metadata { self.site = site self.title = title self.titleSeperator = titleSeperator - self.pageTitle = - "\(title ?? "")\(site.map { " \(titleSeperator) \($0)" } ?? "")" self.description = description self.date = date self.image = image @@ -85,25 +64,7 @@ public struct Metadata { self.type = type self.themeColor = themeColor } -} -extension Metadata { - /// Initializes a new `Metadata` instance based on an existing one, with optional modifications to any property. - /// - /// - Parameters: - /// - base: The original `Metadata` instance. - /// - site: Optional site value, default is `nil`. - /// - title: Optional title value, default is `nil`. - /// - titleSeperator: Optional title separator, default is the same as `base`. - /// - description: Optional description value, default is the same as `base`. - /// - date: Optional date value, default is the same as `base`. - /// - image: Optional image value, default is the same as `base`. - /// - author: Optional author value, default is the same as `base`. - /// - keywords: Optional keywords value, default is the same as `base`. - /// - twitter: Optional twitter handle, default is the same as `base`. - /// - locale: Optional locale value, default is the same as `base`. - /// - type: Optional content type, default is the same as `base`. - /// - themeColor: Optional theme color, default is the same as `base`. public init( from base: Metadata, site: String? = nil, @@ -122,8 +83,6 @@ extension Metadata { self.site = site ?? base.site self.title = title ?? base.title self.titleSeperator = titleSeperator ?? base.titleSeperator - self.pageTitle = - "\(title ?? "")\(site.map { " \(titleSeperator ?? "|") \($0)" } ?? "")" self.description = description ?? base.description self.date = date ?? base.date self.image = image ?? base.image @@ -134,4 +93,39 @@ extension Metadata { self.type = type ?? base.type self.themeColor = themeColor ?? base.themeColor } + + var tags: [String] { + var baseTags: [String] = [ + "", + "", + "", + "", + "", + ] + + if let image, !image.isEmpty { + baseTags.append("") + } + if let author, !author.isEmpty { + baseTags.append("") + } + if let twitter, !twitter.isEmpty { + baseTags.append("") + } + if let keywords, !keywords.isEmpty { + baseTags.append( + "" + ) + } + if let themeColor { + baseTags.append( + "" + ) + baseTags.append( + "" + ) + } + + return baseTags + } } From 029eef3152d4aed6dba1782270e3dedb9a35b9f2 Mon Sep 17 00:00:00 2001 From: Mac Date: Thu, 8 May 2025 18:19:30 +0200 Subject: [PATCH 03/10] fix: move theme generation to `Theme` --- Sources/WebUI/Core/Document.swift | 9 --------- .../WebUI/Core/{Application.swift => StaticSite.swift} | 2 +- 2 files changed, 1 insertion(+), 10 deletions(-) rename Sources/WebUI/Core/{Application.swift => StaticSite.swift} (99%) diff --git a/Sources/WebUI/Core/Document.swift b/Sources/WebUI/Core/Document.swift index 72ac39f2..30d340b0 100644 --- a/Sources/WebUI/Core/Document.swift +++ b/Sources/WebUI/Core/Document.swift @@ -103,15 +103,6 @@ public struct Document { \(metadata.pageTitle) \(optionalTags.joined(separator: "\n")) - \(head ?? "") diff --git a/Sources/WebUI/Core/Application.swift b/Sources/WebUI/Core/StaticSite.swift similarity index 99% rename from Sources/WebUI/Core/Application.swift rename to Sources/WebUI/Core/StaticSite.swift index e30f8827..3390e018 100644 --- a/Sources/WebUI/Core/Application.swift +++ b/Sources/WebUI/Core/StaticSite.swift @@ -2,7 +2,7 @@ import Foundation import Logging /// A structure that manages and builds a collection of routes into HTML files. -public struct Application { +public struct StaticSite { private let logger = Logger(label: "com.webui.application") public let routes: [Document] From f556bc934f547b99bf46c5b565b366658d7a342f Mon Sep 17 00:00:00 2001 From: Mac Date: Thu, 8 May 2025 18:19:44 +0200 Subject: [PATCH 04/10] chore: update platform for latest swift features --- Package.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Package.swift b/Package.swift index c512754d..b736b4a1 100644 --- a/Package.swift +++ b/Package.swift @@ -4,6 +4,7 @@ import PackageDescription let package = Package( name: "web-ui", + platforms: [.macOS(.v15), .iOS(.v13), .tvOS(.v13)], products: [ .library(name: "WebUI", targets: ["WebUI"]) ], From 002cdcd5031ecc4acb8f92f0d4634aee2f03de29 Mon Sep 17 00:00:00 2001 From: Mac Date: Thu, 8 May 2025 18:22:16 +0200 Subject: [PATCH 05/10] chore: remove unused --- .spi.yml | 4 --- Sources/WebUI/Utilities/LoggingSetup.swift | 29 ---------------------- 2 files changed, 33 deletions(-) delete mode 100644 .spi.yml delete mode 100644 Sources/WebUI/Utilities/LoggingSetup.swift diff --git a/.spi.yml b/.spi.yml deleted file mode 100644 index 6d56aebb..00000000 --- a/.spi.yml +++ /dev/null @@ -1,4 +0,0 @@ -version: 1 -builder: - configs: - - documentation_targets: [WebUI] diff --git a/Sources/WebUI/Utilities/LoggingSetup.swift b/Sources/WebUI/Utilities/LoggingSetup.swift deleted file mode 100644 index 51048f45..00000000 --- a/Sources/WebUI/Utilities/LoggingSetup.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Foundation -import Logging - -/// Provides configuration for the logging system. -public struct LoggingSetup { - /// Configures the logging system for the application. - /// - Parameter logLevelString: The string representation of the log level from environment (e.g., "info", "debug"). Defaults to "info". - public static func bootstrap(logLevelString: String = "info") { - let logLevel: Logger.Level - switch logLevelString.lowercased() { - case "trace": logLevel = .trace - case "debug": logLevel = .debug - case "notice": logLevel = .notice - case "warning", "warn": logLevel = .warning - case "error": logLevel = .error - case "critical": logLevel = .critical - default: logLevel = .info - } - - LoggingSystem.bootstrap { label in - var handler = StreamLogHandler.standardOutput(label: label) - handler.logLevel = logLevel - return handler - } - - let logger = Logger(label: "com.webui.setup") - logger.notice("Logging system initialized with log level: \(logLevel)") - } -} From 34cf173d5de9efac16f27df3dfe330cb53f97858 Mon Sep 17 00:00:00 2001 From: Mac Date: Thu, 8 May 2025 18:22:36 +0200 Subject: [PATCH 06/10] fix: move theme generation to css --- Sources/WebUI/Core/Theme.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Sources/WebUI/Core/Theme.swift b/Sources/WebUI/Core/Theme.swift index 51a8e32e..10c843ed 100644 --- a/Sources/WebUI/Core/Theme.swift +++ b/Sources/WebUI/Core/Theme.swift @@ -208,4 +208,16 @@ public struct Theme { logger.debug("CSS generation completed with \(propertyCount) properties") return css } + + public func generateFile() -> String { + """ + @theme { + --breakpoint-xs: 30rem; + --breakpoint-3xl: 120rem; + --breakpoint-4xl: 160rem; + \(self.generateCSS() ?? "") + @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); + } + """ + } } From f9c49a238d2c2f1886f03ffc116645f5185aaa76 Mon Sep 17 00:00:00 2001 From: Mac Date: Fri, 9 May 2025 14:55:01 +0200 Subject: [PATCH 07/10] feat: imrpove static site generation process --- Sources/WebUI/Core/Document.swift | 2 +- Sources/WebUI/Core/Metadata.swift | 14 +- Sources/WebUI/Core/StaticSite.swift | 243 --------------------- Sources/WebUI/Core/Website.swift | 175 +++++++++++++++ Sources/WebUI/Utilities/LoggingSetup.swift | 29 +++ 5 files changed, 213 insertions(+), 250 deletions(-) delete mode 100644 Sources/WebUI/Core/StaticSite.swift create mode 100644 Sources/WebUI/Core/Website.swift create mode 100644 Sources/WebUI/Utilities/LoggingSetup.swift diff --git a/Sources/WebUI/Core/Document.swift b/Sources/WebUI/Core/Document.swift index 30d340b0..74fdd98a 100644 --- a/Sources/WebUI/Core/Document.swift +++ b/Sources/WebUI/Core/Document.swift @@ -22,7 +22,7 @@ public struct Document { public var stylesheets: [String]? public var theme: Theme? public let head: String? - private let contentBuilder: () -> [any HTML] + public let contentBuilder: () -> [any HTML] /// Computed HTML content from the content builder. var content: [any HTML] { diff --git a/Sources/WebUI/Core/Metadata.swift b/Sources/WebUI/Core/Metadata.swift index f2a7fd16..b1984540 100644 --- a/Sources/WebUI/Core/Metadata.swift +++ b/Sources/WebUI/Core/Metadata.swift @@ -11,9 +11,9 @@ public enum Locale: String { public struct ThemeColor { public let light: String - public let dark: String + public let dark: String? - public init(light: String, dark: String) { + public init(_ light: String, dark: String? = nil) { self.light = light self.dark = dark } @@ -119,11 +119,13 @@ public struct Metadata { } if let themeColor { baseTags.append( - "" - ) - baseTags.append( - "" + "" ) + if themeColor.dark != nil { + baseTags.append( + "" + ) + } } return baseTags diff --git a/Sources/WebUI/Core/StaticSite.swift b/Sources/WebUI/Core/StaticSite.swift deleted file mode 100644 index 3390e018..00000000 --- a/Sources/WebUI/Core/StaticSite.swift +++ /dev/null @@ -1,243 +0,0 @@ -import Foundation -import Logging - -/// A structure that manages and builds a collection of routes into HTML files. -public struct StaticSite { - private let logger = Logger(label: "com.webui.application") - public let routes: [Document] - - /// Initializes an application with a collection of routes. - /// - Parameter routes: The routes to be built into HTML files. - public init(routes: [Document]) { - self.routes = routes - logger.info("Application initialized with \(routes.count) routes") - } - - /// Builds the application by generating HTML files for each route in the specified directory. - /// - Parameters: - /// - outputDirectory: The directory where HTML files will be generated. Defaults to `.output`. - /// - assetsPath: The path to the public assets directory. Defaults to `Sources/Public`. - /// - Throws: `BuildError` if directory creation, file creation, or asset copying fails. - /// - Complexity: O(n) where n is the number of routes. - public func build( - to outputDirectory: URL = URL(fileURLWithPath: ".output"), - assetsPath: String = "Sources/Public" - ) throws { - logger.info("Starting build process to \(outputDirectory.path)") - logger.debug("Using assets path: \(assetsPath)") - - let fileManager = FileManager.default - - // Remove existing output directory if it exists - logger.debug("Checking for existing output directory") - try removeExistingDirectory(at: outputDirectory, using: fileManager) - - // Create output directory - logger.debug("Creating output directory") - try createDirectory(at: outputDirectory, using: fileManager) - - // Build each route - logger.info("Building \(routes.count) routes") - var failedRoutes: [String] = [] - try buildRoutes( - routes, - to: outputDirectory, - trackingFailuresIn: &failedRoutes, - using: fileManager - ) - - // Copy assets - logger.info("Copying assets from \(assetsPath) to output") - try copyAssets( - from: URL(fileURLWithPath: assetsPath), - to: outputDirectory.appendingPathComponent("public"), - using: fileManager - ) - - // Throw error if any routes failed - if !failedRoutes.isEmpty { - logger.error("Build completed with \(failedRoutes.count) failed routes") - throw BuildError.failedRoutes(failedRoutes) - } - - logger.notice("Build completed successfully.") - } - - /// Removes an existing directory if it exists. - /// - Parameters: - /// - url: The URL of the directory to remove. - /// - fileManager: The file manager to use for operations. - /// - Throws: `BuildError.directoryCreationFailed` if removal fails. - private func removeExistingDirectory( - at url: URL, - using fileManager: FileManager - ) throws { - if fileManager.fileExists(atPath: url.path) { - logger.debug("Removing existing directory at \(url.path)") - do { - try fileManager.removeItem(at: url) - logger.debug("Successfully removed existing directory") - } catch { - logger.error("Failed to remove existing directory: \(error.localizedDescription)") - throw BuildError.directoryCreationFailed(error) - } - } else { - logger.debug("No existing directory found at \(url.path)") - } - } - - /// Creates a directory at the specified URL. - /// - Parameters: - /// - url: The URL where the directory should be created. - /// - fileManager: The file manager to use for operations. - /// - Throws: `BuildError.directoryCreationFailed` if creation fails. - private func createDirectory( - at url: URL, - using fileManager: FileManager - ) throws { - logger.debug("Creating directory at \(url.path)") - do { - try fileManager.createDirectory( - at: url, - withIntermediateDirectories: true - ) - logger.debug("Successfully created directory") - } catch { - logger.error("Failed to create directory: \(error.localizedDescription)") - throw BuildError.directoryCreationFailed(error) - } - } - - /// Builds routes by creating HTML files in the output directory. - /// - Parameters: - /// - routes: The routes to build. - /// - outputDirectory: The directory where HTML files will be created. - /// - failedRoutes: An array to track routes that fail to build. - /// - fileManager: The file manager to use for operations. - /// - Throws: `BuildError.fileCreationFailed` if a file cannot be created. - private func buildRoutes( - _ routes: [Document], - to outputDirectory: URL, - trackingFailuresIn failedRoutes: inout [String], - using fileManager: FileManager - ) throws { - for (index, route) in routes.enumerated() { - let routePath = route.path ?? "unnamed" - logger.debug("Building route [\(index+1)/\(routes.count)]: \(routePath)") - - do { - let pathComponents = route.path?.split(separator: "/") ?? [""] - let fileName = pathComponents.last.map(String.init) ?? "index" - - logger.trace( - "Creating path for route with components: \(pathComponents), filename: \(fileName)") - let filePath = try createPath( - for: pathComponents, - in: outputDirectory, - with: fileName, - using: fileManager - ) - - logger.debug("Rendering HTML for route: \(routePath)") - let htmlContent = route.render().data(using: .utf8) - - logger.debug("Creating file at: \(filePath.path)") - guard - fileManager.createFile( - atPath: filePath.path, - contents: htmlContent - ) - else { - logger.error("Failed to create file for route: \(routePath)") - throw BuildError.fileCreationFailed( - routePath, - nil - ) - } - - logger.debug("Successfully built route: \(routePath)") - } catch { - logger.error("Failed to build route '\(routePath)': \(error.localizedDescription)") - failedRoutes.append(routePath) - print( - "Failed to build route '\(routePath)': \(error.localizedDescription)") - } - } - - if failedRoutes.isEmpty { - logger.info("All routes built successfully") - } else { - logger.warning("\(failedRoutes.count) routes failed to build") - } - } - - /// Creates the file path for a route, including any necessary directories. - /// - Parameters: - /// - components: The path components of the route. - /// - outputDirectory: The base output directory. - /// - fileName: The name of the HTML file. - /// - fileManager: The file manager to use for operations. - /// - Returns: The URL of the created file path. - /// - Throws: `BuildError.directoryCreationFailed` if directory creation fails. - private func createPath( - for components: [Substring], - in outputDirectory: URL, - with fileName: String, - using fileManager: FileManager - ) throws -> URL { - var currentPath = outputDirectory - - if components.count > 1 { - for component in components.dropLast() { - currentPath = currentPath.appendingPathComponent(String(component)) - logger.trace("Creating directory for path component: \(component)") - try createDirectory(at: currentPath, using: fileManager) - } - } - - let finalPath = currentPath.appendingPathComponent("\(fileName).html") - logger.trace("Final file path created: \(finalPath.path)") - return finalPath - } - - /// Copies assets from the source to the destination directory. - /// - Parameters: - /// - sourceURL: The source URL of the assets. - /// - destinationURL: The destination URL for the assets. - /// - fileManager: The file manager to use for operations. - /// - Throws: `BuildError.publicCopyFailed` if copying fails. - private func copyAssets( - from sourceURL: URL, - to destinationURL: URL, - using fileManager: FileManager - ) throws { - if fileManager.fileExists(atPath: sourceURL.path) { - logger.debug("Assets directory exists at: \(sourceURL.path)") - do { - logger.debug("Copying assets to: \(destinationURL.path)") - try fileManager.copyItem(at: sourceURL, to: destinationURL) - logger.debug("Successfully copied assets") - } catch { - logger.error("Failed to copy assets: \(error.localizedDescription)") - throw BuildError.publicCopyFailed(error) - } - } else { - logger.warning("Assets directory not found at: \(sourceURL.path)") - } - } - - /// Errors that can occur during the build process. - public enum BuildError: Error { - /// Indicates failure to create a directory. - case directoryCreationFailed(Error) - - /// Indicates failure to create a file for a route. - case fileCreationFailed(String, Error?) - - /// Indicates that some routes failed to build. - case failedRoutes([String]) - - /// Indicates failure to copy public assets. - case publicCopyFailed(Error) - } -} diff --git a/Sources/WebUI/Core/Website.swift b/Sources/WebUI/Core/Website.swift new file mode 100644 index 00000000..cf3083be --- /dev/null +++ b/Sources/WebUI/Core/Website.swift @@ -0,0 +1,175 @@ +import Foundation +import Logging + +/// Manages and builds a collection of routes into HTML files. +public struct Website { + private let logger = Logger(label: "com.webui.application") + + public let metadata: Metadata? + public let theme: Theme? + public let stylesheets: [String]? + public let scripts: [String: ScriptAttribute?]? + public let head: String? + public let routes: [Document] + + /// Initializes a static site. + /// - Parameters: + /// - metadata: Optional default metadata for all routes. + /// - theme: Optional default theme for all routes. + /// - stylesheets: Optional stylesheet URLs for all routes. + /// - scripts: Optional JavaScript sources for all routes. + /// - head: Optional custom head tags for all routes. + /// - routes: Routes to build into HTML files. + public init( + metadata: Metadata? = nil, + theme: Theme? = nil, + stylesheets: [String]? = nil, + scripts: [String: ScriptAttribute?]? = nil, + head: String? = nil, + routes: [Document] + ) { + self.metadata = metadata + self.theme = theme + self.stylesheets = stylesheets + self.scripts = scripts + self.head = head + self.routes = routes.map { document in + // Merge scripts: Website scripts + Document scripts (Document scripts override duplicates) + var mergedScripts = scripts ?? [:] + if let documentScripts = document.scripts { + mergedScripts.merge(documentScripts) { _, new in new } + } + + // Merge stylesheets: Website stylesheets + Document stylesheets (combine, remove duplicates) + let mergedStylesheets = Array( + Set((stylesheets ?? []) + (document.stylesheets ?? [])) + ) + + // Merge head: Use Document head if provided, otherwise use Website head + let mergedHead = document.head ?? head + + // Create new Document with merged properties + return Document( + path: document.path, + metadata: document.metadata, + scripts: mergedScripts.isEmpty ? nil : mergedScripts, + stylesheets: mergedStylesheets.isEmpty ? nil : mergedStylesheets, + theme: document.theme ?? theme, + head: mergedHead, + content: document.contentBuilder + ) + } + + logger.info( + "Initialized '\(metadata?.site ?? "Untitled")' with \(routes.count) routes, theme: \(theme != nil ? "set" : "none")" + ) + } + + /// Builds HTML files for each route in the specified directory. + /// - Parameters: + /// - outputDirectory: Directory for generated HTML files. Defaults to `.output`. + /// - assetsPath: Path to public assets. Defaults to `Sources/Public`. + /// - Throws: `BuildError` if file operations fail. + public func build( + to outputDirectory: URL = URL(filePath: ".output"), + assetsPath: String = "Sources/Public" + ) throws { + logger.info( + "Starting build to '\(outputDirectory.path)' with assets from '\(assetsPath)'" + ) + let fileManager = FileManager.default + + // Clear and create output directory + logger.debug( + "Checking for existing output directory at '\(outputDirectory.path)'" + ) + if fileManager.fileExists(atPath: outputDirectory.path) { + logger.trace("Removing existing output directory") + try fileManager.removeItem(at: outputDirectory) + logger.debug("Cleared existing output directory") + } else { + logger.trace("No existing output directory found") + } + + logger.trace("Creating output directory at '\(outputDirectory.path)'") + try fileManager.createDirectory( + at: outputDirectory, + withIntermediateDirectories: true + ) + logger.debug("Created output directory") + + // Build routes + logger.info("Building \(routes.count) routes") + var failedRoutes: [String] = [] + for (index, route) in routes.enumerated() { + let routePath = route.path ?? "unnamed" + logger.debug( + "Processing route [\(index+1)/\(routes.count)]: '\(routePath)'" + ) + + do { + let pathComponents = route.path?.split(separator: "/") ?? [""] + let fileName = pathComponents.last.map(String.init) ?? "index" + var currentPath = outputDirectory + + // Create intermediate directories + if pathComponents.count > 1 { + logger.trace( + "Creating directories for path components: \(pathComponents.dropLast())" + ) + for component in pathComponents.dropLast() { + currentPath.appendPathComponent(String(component)) + try fileManager.createDirectory( + at: currentPath, + withIntermediateDirectories: true + ) + } + logger.debug("Created intermediate directories for '\(routePath)'") + } + + // Write HTML file + let filePath = currentPath.appendingPathComponent("\(fileName).html") + logger.trace("Rendering HTML for '\(routePath)' to '\(filePath.path)'") + let htmlContent = route.render().data(using: .utf8) + guard + fileManager.createFile(atPath: filePath.path, contents: htmlContent) + else { + throw BuildError.fileCreationFailed(routePath) + } + logger.debug("Successfully wrote HTML file for '\(routePath)'") + } catch { + logger.error("Failed to build route '\(routePath)': \(error)") + failedRoutes.append(routePath) + } + } + + // Copy assets + let sourceURL = URL(fileURLWithPath: assetsPath) + let destinationURL = outputDirectory.appendingPathComponent("public") + logger.info( + "Copying assets from '\(sourceURL.path)' to '\(destinationURL.path)'" + ) + if fileManager.fileExists(atPath: sourceURL.path) { + logger.trace("Copying assets directory") + try fileManager.copyItem(at: sourceURL, to: destinationURL) + logger.debug("Successfully copied assets") + } else { + logger.warning("Assets directory not found at '\(sourceURL.path)'") + } + + // Report failures + if !failedRoutes.isEmpty { + logger.error( + "Build completed with \(failedRoutes.count) failed routes: \(failedRoutes.joined(separator: ", "))" + ) + throw BuildError.failedRoutes(failedRoutes) + } + logger.info("Build completed successfully with \(routes.count) routes") + } + + /// Errors during the build process. + public enum BuildError: Error { + case fileCreationFailed(String) + case failedRoutes([String]) + } +} diff --git a/Sources/WebUI/Utilities/LoggingSetup.swift b/Sources/WebUI/Utilities/LoggingSetup.swift new file mode 100644 index 00000000..51048f45 --- /dev/null +++ b/Sources/WebUI/Utilities/LoggingSetup.swift @@ -0,0 +1,29 @@ +import Foundation +import Logging + +/// Provides configuration for the logging system. +public struct LoggingSetup { + /// Configures the logging system for the application. + /// - Parameter logLevelString: The string representation of the log level from environment (e.g., "info", "debug"). Defaults to "info". + public static func bootstrap(logLevelString: String = "info") { + let logLevel: Logger.Level + switch logLevelString.lowercased() { + case "trace": logLevel = .trace + case "debug": logLevel = .debug + case "notice": logLevel = .notice + case "warning", "warn": logLevel = .warning + case "error": logLevel = .error + case "critical": logLevel = .critical + default: logLevel = .info + } + + LoggingSystem.bootstrap { label in + var handler = StreamLogHandler.standardOutput(label: label) + handler.logLevel = logLevel + return handler + } + + let logger = Logger(label: "com.webui.setup") + logger.notice("Logging system initialized with log level: \(logLevel)") + } +} From 1272f075d5e80a6097855dad8cebbbf58c495228 Mon Sep 17 00:00:00 2001 From: Mac Date: Fri, 9 May 2025 14:55:10 +0200 Subject: [PATCH 08/10] chore: formatting --- Package.resolved | 51 ------------------- Sources/WebUI/Core/Theme.swift | 2 +- Sources/WebUI/Elements/Base/Text.swift | 2 +- Tests/WebUITests/Styles/AppearanceTests.swift | 2 +- 4 files changed, 3 insertions(+), 54 deletions(-) delete mode 100644 Package.resolved diff --git a/Package.resolved b/Package.resolved deleted file mode 100644 index eb6dd308..00000000 --- a/Package.resolved +++ /dev/null @@ -1,51 +0,0 @@ -{ - "originHash" : "1ccf4329cf20c417c5f7a71949a5e37c07f5d9d2b8bc5845976fcb430278ab5a", - "pins" : [ - { - "identity" : "swift-cmark", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-cmark.git", - "state" : { - "revision" : "b022b08312decdc46585e0b3440d97f6f22ef703", - "version" : "0.6.0" - } - }, - { - "identity" : "swift-docc-plugin", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-docc-plugin", - "state" : { - "revision" : "85e4bb4e1cd62cec64a4b8e769dcefdf0c5b9d64", - "version" : "1.4.3" - } - }, - { - "identity" : "swift-docc-symbolkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-docc-symbolkit", - "state" : { - "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", - "version" : "1.0.0" - } - }, - { - "identity" : "swift-log", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-log.git", - "state" : { - "revision" : "3d8596ed08bd13520157f0355e35caed215ffbfa", - "version" : "1.6.3" - } - }, - { - "identity" : "swift-markdown", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-markdown", - "state" : { - "revision" : "ea79e83c8744d2b50b0dc2d5bbd1e857e1253bf9", - "version" : "0.6.0" - } - } - ], - "version" : 3 -} diff --git a/Sources/WebUI/Core/Theme.swift b/Sources/WebUI/Core/Theme.swift index 10c843ed..8fc28dd7 100644 --- a/Sources/WebUI/Core/Theme.swift +++ b/Sources/WebUI/Core/Theme.swift @@ -208,7 +208,7 @@ public struct Theme { logger.debug("CSS generation completed with \(propertyCount) properties") return css } - + public func generateFile() -> String { """ @theme { diff --git a/Sources/WebUI/Elements/Base/Text.swift b/Sources/WebUI/Elements/Base/Text.swift index 94a8fa5f..9bdff69b 100644 --- a/Sources/WebUI/Elements/Base/Text.swift +++ b/Sources/WebUI/Elements/Base/Text.swift @@ -68,7 +68,7 @@ public final class Heading: Element { /// - data: Dictionary of `data-*` attributes for element relevant storing data. /// - content: Closure providing heading content. public init( - level: HeadingLevel, + _ level: HeadingLevel, id: String? = nil, classes: [String]? = nil, role: AriaRole? = nil, diff --git a/Tests/WebUITests/Styles/AppearanceTests.swift b/Tests/WebUITests/Styles/AppearanceTests.swift index ae792dda..f14b61da 100644 --- a/Tests/WebUITests/Styles/AppearanceTests.swift +++ b/Tests/WebUITests/Styles/AppearanceTests.swift @@ -71,7 +71,7 @@ import Testing let rendered = element.render() #expect(rendered.contains("class=\"rounded-md\"")) } - + @Test("Border with radius on just one side") func testBorderWithOneSidedRadius() async throws { let element = Element(tag: "div").rounded(.full, .topLeft) From 25e79c381ad06ae02f653eafbdd821acfaf22b82 Mon Sep 17 00:00:00 2001 From: Mac Date: Fri, 9 May 2025 15:52:33 +0200 Subject: [PATCH 09/10] test: update tests to match library changes --- Package.resolved | 51 ++++++++++++++++++++ Sources/WebUI/Core/Metadata.swift | 6 +-- Sources/WebUI/Core/Theme.swift | 2 +- Tests/WebUITests/Core/ApplicationTests.swift | 6 +-- Tests/WebUITests/Core/DocumentTests.swift | 5 +- Tests/WebUITests/Core/MetadataTests.swift | 12 +++-- Tests/WebUITests/ElementTests.swift | 18 +++---- 7 files changed, 77 insertions(+), 23 deletions(-) create mode 100644 Package.resolved diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 00000000..cc146997 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,51 @@ +{ + "originHash" : "27fc44db5d602a3eb6052ee08ebaf473001ed8db7fa1b54b79f7f4682568491d", + "pins" : [ + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark.git", + "state" : { + "revision" : "b022b08312decdc46585e0b3440d97f6f22ef703", + "version" : "0.6.0" + } + }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-docc-plugin", + "state" : { + "revision" : "85e4bb4e1cd62cec64a4b8e769dcefdf0c5b9d64", + "version" : "1.4.3" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "3d8596ed08bd13520157f0355e35caed215ffbfa", + "version" : "1.6.3" + } + }, + { + "identity" : "swift-markdown", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-markdown", + "state" : { + "revision" : "ea79e83c8744d2b50b0dc2d5bbd1e857e1253bf9", + "version" : "0.6.0" + } + } + ], + "version" : 3 +} diff --git a/Sources/WebUI/Core/Metadata.swift b/Sources/WebUI/Core/Metadata.swift index b1984540..8127db28 100644 --- a/Sources/WebUI/Core/Metadata.swift +++ b/Sources/WebUI/Core/Metadata.swift @@ -22,7 +22,7 @@ public struct ThemeColor { public struct Metadata { public var site: String? public var title: String? - public var titleSeperator: String + public var titleSeperator: String? public var description: String public var date: Date? public var image: String? @@ -34,13 +34,13 @@ public struct Metadata { public var themeColor: ThemeColor? public var pageTitle: String { - "\(title ?? "")\(titleSeperator)\(site ?? "")" + "\(title ?? "")\(titleSeperator ?? "")\(site ?? "")" } public init( site: String? = nil, title: String? = nil, - titleSeperator: String = " | ", + titleSeperator: String? = " | ", description: String, date: Date? = nil, image: String? = nil, diff --git a/Sources/WebUI/Core/Theme.swift b/Sources/WebUI/Core/Theme.swift index 8fc28dd7..df796bc9 100644 --- a/Sources/WebUI/Core/Theme.swift +++ b/Sources/WebUI/Core/Theme.swift @@ -215,7 +215,7 @@ public struct Theme { --breakpoint-xs: 30rem; --breakpoint-3xl: 120rem; --breakpoint-4xl: 160rem; - \(self.generateCSS() ?? "") + \(self.generateCSS()) @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); } """ diff --git a/Tests/WebUITests/Core/ApplicationTests.swift b/Tests/WebUITests/Core/ApplicationTests.swift index ddb4bd58..47bbf059 100644 --- a/Tests/WebUITests/Core/ApplicationTests.swift +++ b/Tests/WebUITests/Core/ApplicationTests.swift @@ -6,7 +6,7 @@ import Testing @Suite("Application Tests") struct ApplicationTests { @Test("Creates the build directory and populates correctly") func createsAndPopulatesBuildDirectory() throws { - let app = Application( + let app = Website( routes: [ Document( path: "index", @@ -22,7 +22,7 @@ import Testing } Main { Stack { - Heading(level: .one) { "Tagline" } + Heading(.one) { "Tagline" } Text { "Lorem ipsum dolor sit amet." } } } @@ -35,7 +35,7 @@ import Testing metadata: .init(title: "About", description: "Learn more here") ) { Article { - Heading(level: .two) { "Article Heading" } + Heading(.two) { "Article Heading" } Text { "Lorem ipsum dolor sit amet." } } }, diff --git a/Tests/WebUITests/Core/DocumentTests.swift b/Tests/WebUITests/Core/DocumentTests.swift index 093e1c36..19262a91 100644 --- a/Tests/WebUITests/Core/DocumentTests.swift +++ b/Tests/WebUITests/Core/DocumentTests.swift @@ -11,6 +11,7 @@ import Testing metadata: Metadata( site: "Test Site", title: "Hello World", + titleSeperator: " | ", description: "A test description" ) ) { @@ -40,7 +41,7 @@ import Testing metadata: Metadata( site: "Test Site", title: "Full Test", - titleSeperator: "-", + titleSeperator: " - ", description: "A complete metadata test", date: Date(), image: "https://example.com/image.png", @@ -49,7 +50,7 @@ import Testing twitter: "testhandle", locale: .ru, type: .article, - themeColor: .init(light: "#0099ff", dark: "#1c1c1c"), + themeColor: .init("#0099ff", dark: "#1c1c1c"), ) ) { "Content" diff --git a/Tests/WebUITests/Core/MetadataTests.swift b/Tests/WebUITests/Core/MetadataTests.swift index 9c757ac3..02dd8aa9 100644 --- a/Tests/WebUITests/Core/MetadataTests.swift +++ b/Tests/WebUITests/Core/MetadataTests.swift @@ -11,15 +11,16 @@ struct MetadataTests { let metadata = Metadata( site: "Test Site", title: "Test Title", + titleSeperator: " | ", description: "Test description", - themeColor: .init(light: "#0099ff", dark: "#1c1c1c") + themeColor: .init("#0099ff", dark: "#1c1c1c") ) // Assert #expect(metadata.site == "Test Site") #expect(metadata.title == "Test Title") #expect(metadata.description == "Test description") - #expect(metadata.titleSeperator == "|") + #expect(metadata.titleSeperator == " | ") #expect(metadata.pageTitle == "Test Title | Test Site") #expect(metadata.locale == .en) #expect(metadata.themeColor?.light == "#0099ff") @@ -30,6 +31,7 @@ struct MetadataTests { @Test func testNoSiteMetadata() throws { let metadata = Metadata( title: "Just Title", + titleSeperator: nil, description: "No site metadata" ) @@ -42,11 +44,11 @@ struct MetadataTests { let metadata = Metadata( site: "My Site", title: "My Title", - titleSeperator: "-", + titleSeperator: " - ", description: "Custom separator test" ) - #expect(metadata.titleSeperator == "-") + #expect(metadata.titleSeperator == " - ") #expect(metadata.pageTitle == "My Title - My Site") } @@ -58,7 +60,7 @@ struct MetadataTests { let metadata = Metadata( site: "Full Site", title: "Full Title", - titleSeperator: ":", + titleSeperator: " : ", description: "Full metadata description", date: testDate, image: "/images/test.jpg", diff --git a/Tests/WebUITests/ElementTests.swift b/Tests/WebUITests/ElementTests.swift index e1e90120..88983a4d 100644 --- a/Tests/WebUITests/ElementTests.swift +++ b/Tests/WebUITests/ElementTests.swift @@ -292,9 +292,9 @@ import Testing @Test("Heading element") func testHeadingElement() async throws { - let heading1 = Heading(level: .one) { "Main Title" } - let heading2 = Heading(level: .two) { "Subtitle" } - let heading6 = Heading(level: .six) { "Small Heading" } + let heading1 = Heading(.one) { "Main Title" } + let heading2 = Heading(.two) { "Subtitle" } + let heading6 = Heading(.six) { "Small Heading" } #expect(heading1.render() == "

Main Title

") #expect(heading2.render() == "

Subtitle

") @@ -484,7 +484,7 @@ import Testing @Test("Header element") func testHeaderElement() async throws { let header = Header(id: "page-header") { - Heading(level: .one) { "Site Title" } + Heading(.one) { "Site Title" } } let rendered = header.render() @@ -551,7 +551,7 @@ import Testing @Test("Article element") func testArticleElement() async throws { let article = Article(id: "blog-post") { - Heading(level: .two) { "Article Title" } + Heading(.two) { "Article Title" } Text { "Article content" } } @@ -566,7 +566,7 @@ import Testing @Test("Section element") func testSectionElement() async throws { let section = Section(id: "features") { - Heading(level: .three) { "Features" } + Heading(.three) { "Features" } Text { "Feature list" } } @@ -649,7 +649,7 @@ import Testing func testPageLayout() async throws { let page = Fragment { Header(id: "main-header") { - Heading(level: .one) { "My Website" } + Heading(.one) { "My Website" } Navigation { List(type: .unordered, classes: ["nav-links"]) { Item { Link(to: "/") { "Home" } } @@ -660,12 +660,12 @@ import Testing } Main { Article { - Heading(level: .two) { "Welcome" } + Heading(.two) { "Welcome" } Text { "This is the main content of the page." } } } Aside(id: "sidebar") { - Heading(level: .three) { "Related Links" } + Heading(.three) { "Related Links" } List(type: .unordered) { Item { Link(to: "/link1") { "Link 1" } } Item { Link(to: "/link2") { "Link 2" } } From 3253026c7680f265d9f44af2fe76a200528930ae Mon Sep 17 00:00:00 2001 From: Mac Date: Fri, 9 May 2025 15:53:53 +0200 Subject: [PATCH 10/10] fix: guard statement on meta theme color corrected --- Sources/WebUI/Core/Metadata.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/WebUI/Core/Metadata.swift b/Sources/WebUI/Core/Metadata.swift index 8127db28..5c08051f 100644 --- a/Sources/WebUI/Core/Metadata.swift +++ b/Sources/WebUI/Core/Metadata.swift @@ -40,7 +40,7 @@ public struct Metadata { public init( site: String? = nil, title: String? = nil, - titleSeperator: String? = " | ", + titleSeperator: String? = " ", description: String, date: Date? = nil, image: String? = nil, @@ -121,9 +121,9 @@ public struct Metadata { baseTags.append( "" ) - if themeColor.dark != nil { + if let themeDark = themeColor.dark { baseTags.append( - "" + "" ) } }