diff --git a/Sources/Slipstream/Fundamentals/AnyView.swift b/Sources/Slipstream/Fundamentals/AnyView.swift index e1c147da..67273975 100644 --- a/Sources/Slipstream/Fundamentals/AnyView.swift +++ b/Sources/Slipstream/Fundamentals/AnyView.swift @@ -14,6 +14,11 @@ public struct AnyView: View { public func render(_ container: Element, environment: EnvironmentValues) throws { try injectEnvironment(environment: environment).view.render(container, environment: environment) } + + @_documentation(visibility: private) + public func style(environment: EnvironmentValues) async throws { + try await injectEnvironment(environment: environment).view.style(environment: environment) + } private let view: any View } diff --git a/Sources/Slipstream/Fundamentals/AttributeModifier.swift b/Sources/Slipstream/Fundamentals/AttributeModifier.swift index 206a11ea..7056abc1 100644 --- a/Sources/Slipstream/Fundamentals/AttributeModifier.swift +++ b/Sources/Slipstream/Fundamentals/AttributeModifier.swift @@ -181,6 +181,10 @@ where Content: Sendable { try container.appendChild(child) } } + + func style(environment: EnvironmentValues) async throws { + try await self.content().style(environment: environment) + } } /// A modifier that conditionally sets an HTML attribute on views based on a boolean condition. diff --git a/Sources/Slipstream/Fundamentals/ClassModifier.swift b/Sources/Slipstream/Fundamentals/ClassModifier.swift index d28019e1..37a7302c 100644 --- a/Sources/Slipstream/Fundamentals/ClassModifier.swift +++ b/Sources/Slipstream/Fundamentals/ClassModifier.swift @@ -69,6 +69,10 @@ private struct ClassModifierView: View { try container.appendChild(child) } } + + public func style(environment: EnvironmentValues) async throws { + try await self.content().style(environment: environment) + } private let classNames: Set private let content: @Sendable () -> Content diff --git a/Sources/Slipstream/Fundamentals/DataAndStorage/EnvironmentValues.swift b/Sources/Slipstream/Fundamentals/DataAndStorage/EnvironmentValues.swift index aded326d..9336ed85 100644 --- a/Sources/Slipstream/Fundamentals/DataAndStorage/EnvironmentValues.swift +++ b/Sources/Slipstream/Fundamentals/DataAndStorage/EnvironmentValues.swift @@ -51,3 +51,20 @@ public struct EnvironmentValues: Sendable { private var storage: [ObjectIdentifier: Any & Sendable] = [:] } + +// MARK: - Style Context + +private struct StyleContextKey: EnvironmentKey { + static let defaultValue: StyleContext? = nil +} + +extension EnvironmentValues { + /// The context for collecting CSS components during style traversal. + /// + /// This is used internally by the rendering system to automatically + /// collect components that conform to `StyleModifier`. + var styleContext: StyleContext? { + get { self[StyleContextKey.self] } + set { self[StyleContextKey.self] = newValue } + } +} diff --git a/Sources/Slipstream/Fundamentals/View+styleCollection.swift b/Sources/Slipstream/Fundamentals/View+styleCollection.swift new file mode 100644 index 00000000..4f781739 --- /dev/null +++ b/Sources/Slipstream/Fundamentals/View+styleCollection.swift @@ -0,0 +1,35 @@ +import SwiftSoup + +/// Extension providing CSS style collection for views. +/// +/// The `style()` method traverses the view hierarchy in the same manner as `render()`, +/// allowing CSS components to be collected automatically from views conforming to +/// `StyleModifier`. +@available(iOS 17.0, macOS 14.0, *) +extension View { + /// Collects CSS styles from this view and its descendants. + /// + /// This method recursively traverses the view hierarchy, similar to the traversal + /// pattern of `render()`. Views conforming to `StyleModifier` are automatically + /// registered with the style context. + /// + /// This default implementation recurses the style call on `body`'s contents + /// and is sufficient for most custom `View`-conforming types. + /// + /// - Parameter environment: The environment values for this view. + public func style(environment: EnvironmentValues) async throws { + // Register if this view conforms to StyleModifier + if let modifier = self as? any StyleModifier { + await environment.styleContext?.add(modifier) + } + + // Only traverse body if Content is not Never + // This check happens at runtime to avoid accessing .body on leaf views + guard Content.self != Never.self else { + return // Leaf view - no body to traverse + } + + // Traverse body (exactly like render() does) + try await injectEnvironment(environment: environment).body.style(environment: environment) + } +} diff --git a/Sources/Slipstream/Fundamentals/View.swift b/Sources/Slipstream/Fundamentals/View.swift index 561af3bb..5cc8e913 100644 --- a/Sources/Slipstream/Fundamentals/View.swift +++ b/Sources/Slipstream/Fundamentals/View.swift @@ -52,6 +52,16 @@ public protocol View: Sendable { /// If this method is not implemented, a default implementation will be /// provided that recurses the render calls on `body`. func render(_ container: Element, environment: EnvironmentValues) throws + + /// Traverses the view hierarchy to collect CSS styles from components. + /// + /// This method is used internally by the rendering system to automatically + /// collect CSS from views conforming to `StyleModifier`. You typically don't + /// need to implement this method directly. + /// + /// If this method is not implemented, a default implementation will be + /// provided that recurses the style calls on `body`. + func style(environment: EnvironmentValues) async throws } extension View { diff --git a/Sources/Slipstream/Fundamentals/ViewBuilder/ArrayView.swift b/Sources/Slipstream/Fundamentals/ViewBuilder/ArrayView.swift index 737fa5cb..9fa74fa7 100644 --- a/Sources/Slipstream/Fundamentals/ViewBuilder/ArrayView.swift +++ b/Sources/Slipstream/Fundamentals/ViewBuilder/ArrayView.swift @@ -17,4 +17,10 @@ public struct ArrayView: View { try view.render(container, environment: environment) } } + + public func style(environment: EnvironmentValues) async throws { + for view in array { + try await view.style(environment: environment) + } + } } diff --git a/Sources/Slipstream/Fundamentals/ViewBuilder/ConditionalView.swift b/Sources/Slipstream/Fundamentals/ViewBuilder/ConditionalView.swift index d7d97cd0..385be754 100644 --- a/Sources/Slipstream/Fundamentals/ViewBuilder/ConditionalView.swift +++ b/Sources/Slipstream/Fundamentals/ViewBuilder/ConditionalView.swift @@ -23,4 +23,13 @@ public struct ConditionalView: View { try view.render(container, environment: environment) } } + + public func style(environment: EnvironmentValues) async throws { + switch condition { + case .isTrue(let view): + try await view.style(environment: environment) + case .isFalse(let view): + try await view.style(environment: environment) + } + } } diff --git a/Sources/Slipstream/Fundamentals/ViewBuilder/ForEachView.swift b/Sources/Slipstream/Fundamentals/ViewBuilder/ForEachView.swift index 9a306d03..0d5af0bc 100644 --- a/Sources/Slipstream/Fundamentals/ViewBuilder/ForEachView.swift +++ b/Sources/Slipstream/Fundamentals/ViewBuilder/ForEachView.swift @@ -42,6 +42,10 @@ public struct ForEach: View where Data: RandomAccessCollectio public func render(_ container: Element, environment: EnvironmentValues) throws { try arrayView.render(container, environment: environment) } + + public func style(environment: EnvironmentValues) async throws { + try await arrayView.style(environment: environment) + } } /// Convenience initializer for collections where elements conform to `Identifiable`. diff --git a/Sources/Slipstream/Fundamentals/ViewBuilder/TupleView.swift b/Sources/Slipstream/Fundamentals/ViewBuilder/TupleView.swift index 1b5ec0a3..4b84fe7a 100644 --- a/Sources/Slipstream/Fundamentals/ViewBuilder/TupleView.swift +++ b/Sources/Slipstream/Fundamentals/ViewBuilder/TupleView.swift @@ -21,4 +21,14 @@ where T: Sendable { .compactMap { $0.value as? any View } .forEach { try $0.render(container, environment: environment) } } + + public func style(environment: EnvironmentValues) async throws { + /// Our tuple may be composed of any number of View types, so we use Mirror to + /// read the sub-types of the tuple and traverse each view's style() method. + for child in Mirror(reflecting: value).children { + if let view = child.value as? any View { + try await view.style(environment: environment) + } + } + } } diff --git a/Sources/Slipstream/Rendering/RenderSitemap.swift b/Sources/Slipstream/Rendering/RenderSitemap.swift index 7ab65ff7..af0d455e 100644 --- a/Sources/Slipstream/Rendering/RenderSitemap.swift +++ b/Sources/Slipstream/Rendering/RenderSitemap.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftSoup /// Renders the given sitemap to a folder. /// @@ -18,6 +19,60 @@ public func renderSitemap(_ sitemap: Sitemap, to folder: URL, encoding: String.E } } +/// Renders the given sitemap to a folder with CSS component collection. +/// +/// This async variant automatically collects CSS from components conforming to `StyleModifier` +/// by traversing the view hierarchy. The collected CSS is combined with the base CSS file +/// and written to the specified stylesheet location within the site folder. +/// +/// - Parameter sitemap: A mapping of relative paths to Slipstream views. +/// - Parameter folder: The root folder of the sitemap where HTML and CSS files are generated. +/// - Parameter baseCSS: URL to the base CSS file that will be combined with component styles. +/// - Parameter stylesheet: Path relative to `folder` where the generated CSS will be written. Defaults to "styles.css". +/// - Parameter encoding: The encoding to use when saving files to disk. +/// - Throws: A SwiftSoup `Exception.Error` may be thrown if a failure occurs while rendering the view. +public func renderSitemap( + _ sitemap: Sitemap, + to folder: URL, + baseCSS: URL, + stylesheet: String = "styles.css", + encoding: String.Encoding = .utf8 +) async throws { + var environment = EnvironmentValues() + let styleContext = StyleContext() + environment.styleContext = styleContext + + // Traverse all views to collect CSS components + // This must complete before CSS generation begins + for (_, view) in sitemap { + try await view.style(environment: environment) + } + + let allComponents = await styleContext.allComponents + + // Generate CSS file to folder + stylesheet path + let stylesheetURL = folder.appending(path: stylesheet) + try renderStyles( + from: allComponents, + baseCSS: baseCSS, + to: stylesheetURL + ) + + // Render HTML pages + for (path, view) in sitemap.sorted(by: { $0.key < $1.key }) { + let document = Document("/") + try view.render(document, environment: environment) + let output = try "\n" + document.html() + + let fileURL = folder.appending(path: path) + let folderURL = fileURL.deletingLastPathComponent() + if !FileManager.default.fileExists(atPath: folderURL.path(percentEncoded: false)) { + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + } + try output.write(to: fileURL, atomically: true, encoding: encoding) + } +} + /// Renders a sitemap in parallel and returns the rendered pages. /// /// - Parameter sitemap: A mapping of relative paths to Slipstream views. diff --git a/Sources/Slipstream/Rendering/RenderStyles.swift b/Sources/Slipstream/Rendering/RenderStyles.swift new file mode 100644 index 00000000..76cc9c32 --- /dev/null +++ b/Sources/Slipstream/Rendering/RenderStyles.swift @@ -0,0 +1,71 @@ +import Foundation + +/// Renders styles by combining base CSS with component-specific styles. +/// +/// This function reads a base CSS file and appends CSS from all component instances +/// conforming to `StyleModifier`, then writes the combined result to an output file. +/// Component styles are automatically deduplicated by CSS content and wrapped in `@layer components` +/// to ensure proper Tailwind CSS cascade order (base → components → utilities). +/// +/// Components with identical CSS content are automatically deduplicated (first occurrence wins). +/// This is useful when the same component (e.g., a site header) is used across multiple pages. +/// +/// - Parameter components: Array of component instances that conform to `StyleModifier`. +/// - Parameter baseCSS: URL to the base CSS file to read. +/// - Parameter output: URL where the combined CSS should be written. +/// - Parameter useComponentLayer: Whether to wrap component styles in `@layer components` for Tailwind CSS v3 +/// cascade ordering. Defaults to `true`. Set to `false` for Tailwind CSS v4's automatic ordering. +/// - Throws: `CocoaError` if the base CSS file cannot be read, or if the output file or directory cannot be created or written. +@available(iOS 17.0, macOS 14.0, *) +func renderStyles( + from components: [any StyleModifier], + baseCSS: URL, + to output: URL, + useComponentLayer: Bool = true +) throws { + var cssContent = "" + + // Read base CSS file + let baseContent = try String(contentsOf: baseCSS, encoding: .utf8) + cssContent += baseContent + cssContent += "\n\n" + + // Deduplicate components by CSS content (first occurrence wins) + var seenCSS = Set() + let uniqueComponents = components.filter { component in + seenCSS.insert(component.style).inserted + } + + // Add component-specific styles + if useComponentLayer { + cssContent += "@layer components {\n" + } + + for component in uniqueComponents { + let indent = useComponentLayer ? " " : "" + cssContent += "\(indent)/* \(component.componentName) */\n" + + // Conditionally indent each line of component CSS + if useComponentLayer { + let indentedCSS = component.style + .split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.isEmpty ? "" : " \($0)" } + .joined(separator: "\n") + cssContent += indentedCSS + } else { + cssContent += component.style + } + cssContent += "\n\n" + } + + if useComponentLayer { + cssContent += "}\n" + } + + // Ensure output directory exists + let outputDir = output.deletingLastPathComponent() + try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true) + + // Write combined CSS to output file + try cssContent.write(to: output, atomically: true, encoding: .utf8) +} diff --git a/Sources/Slipstream/Rendering/StyleContext.swift b/Sources/Slipstream/Rendering/StyleContext.swift new file mode 100644 index 00000000..a036b835 --- /dev/null +++ b/Sources/Slipstream/Rendering/StyleContext.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Context for collecting CSS components during view traversal. +/// +/// This context is used internally by Slipstream to automatically collect +/// components that conform to `StyleModifier` while traversing the view +/// hierarchy via the `style()` method. +@available(iOS 17.0, macOS 14.0, *) +actor StyleContext { + private var components: [any StyleModifier] = [] + + /// Adds a component to the collection. + /// + /// - Parameter component: A component conforming to `StyleModifier`. + func add(_ component: any StyleModifier) { + components.append(component) + } + + /// All collected components. + var allComponents: [any StyleModifier] { + components + } +} diff --git a/Sources/Slipstream/Rendering/StyleModifier.swift b/Sources/Slipstream/Rendering/StyleModifier.swift new file mode 100644 index 00000000..fa9a6c63 --- /dev/null +++ b/Sources/Slipstream/Rendering/StyleModifier.swift @@ -0,0 +1,45 @@ +import Foundation +import SwiftSoup + +/// A protocol for components that provide CSS styles. +/// +/// Components conforming to this protocol can expose CSS styles that should be +/// included in the final stylesheet during the build process. +/// +/// Example: +/// ```swift +/// struct MyComponent: View, StyleModifier { +/// var style: String { +/// """ +/// .my-component { +/// background-color: red; +/// } +/// """ +/// } +/// +/// var componentName: String { "MyComponent" } +/// +/// var body: some View { +/// // Component implementation +/// } +/// } +/// ``` +@available(iOS 17.0, macOS 14.0, *) +public protocol StyleModifier: Sendable { + /// The CSS styles for this component instance. + var style: String { get } + + /// A descriptive name for this component (used in CSS comments). + var componentName: String { get } +} + +@available(iOS 17.0, macOS 14.0, *) +public extension StyleModifier { + /// Default component name derived from the type name. + /// + /// This default implementation uses Swift's type reflection to generate + /// a component name from the conforming type's name. + var componentName: String { + String(describing: type(of: self)) + } +} diff --git a/Sources/Slipstream/TailwindCSS/TailwindClassModifier.swift b/Sources/Slipstream/TailwindCSS/TailwindClassModifier.swift index f1a67080..6f2fa442 100644 --- a/Sources/Slipstream/TailwindCSS/TailwindClassModifier.swift +++ b/Sources/Slipstream/TailwindCSS/TailwindClassModifier.swift @@ -61,6 +61,10 @@ private struct TailwindClassModifierView: View { try container.appendChild(child) } } + + public func style(environment: EnvironmentValues) async throws { + try await self.content().style(environment: environment) + } private let classNames: Set private let condition: Condition? diff --git a/Sources/Slipstream/W3C/Elements/W3CElement.swift b/Sources/Slipstream/W3C/Elements/W3CElement.swift index 268f07a0..77adf965 100644 --- a/Sources/Slipstream/W3C/Elements/W3CElement.swift +++ b/Sources/Slipstream/W3C/Elements/W3CElement.swift @@ -25,4 +25,11 @@ extension W3CElement { let element = try container.appendElement(tagName) try self.content().render(element, environment: environment) } + + /// A default implementation for W3C element views that collects CSS styles + /// by traversing the content closure. + @_documentation(visibility: private) + public func style(environment: EnvironmentValues) async throws { + try await self.content().style(environment: environment) + } } diff --git a/Tests/SlipstreamTests/Rendering/RenderSitemapCSSTests.swift b/Tests/SlipstreamTests/Rendering/RenderSitemapCSSTests.swift new file mode 100644 index 00000000..79083030 --- /dev/null +++ b/Tests/SlipstreamTests/Rendering/RenderSitemapCSSTests.swift @@ -0,0 +1,152 @@ +import Foundation +import Testing + +@testable import Slipstream + +final class RenderSitemapCSSTests { + let rootURL: URL + + init() throws { + self.rootURL = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) + } + + deinit { + try? FileManager.default.removeItem(at: rootURL) + } + + @Test func rendersHTMLWithoutCSS() async throws { + let sitemap: Sitemap = ["index.html": Text("Hello")] + try renderSitemap(sitemap, to: rootURL) + + let html = try String(contentsOf: rootURL.appending(path: "index.html"), encoding: .utf8) + #expect(html.contains("Hello")) + } + + @Test func collectsAndGeneratesCSS() async throws { + struct TestComponent: View, StyleModifier { + var style: String { ".test { color: red; }" } + var body: some View { Text("Test") } + } + + let baseCSSURL = rootURL.appending(path: "base.css") + + try "/* Base */".write(to: baseCSSURL, atomically: true, encoding: .utf8) + + let sitemap: Sitemap = ["index.html": TestComponent()] + try await renderSitemap( + sitemap, + to: rootURL, + baseCSS: baseCSSURL, + stylesheet: "output.css" + ) + + let css = try String(contentsOf: rootURL.appending(path: "output.css"), encoding: .utf8) + #expect(css.contains("/* Base */")) + #expect(css.contains(".test { color: red; }")) + #expect(css.contains("@layer components")) + } + + @Test func deduplicatesSharedComponents() async throws { + struct Header: View, StyleModifier { + var style: String { ".header { padding: 10px; }" } + var body: some View { Text("Header") } + } + + let baseCSSURL = rootURL.appending(path: "base.css") + try "".write(to: baseCSSURL, atomically: true, encoding: .utf8) + + let sitemap: Sitemap = [ + "page1.html": Header(), + "page2.html": Header(), + "page3.html": Header() + ] + + try await renderSitemap( + sitemap, + to: rootURL, + baseCSS: baseCSSURL, + stylesheet: "output.css" + ) + + let css = try String(contentsOf: rootURL.appending(path: "output.css"), encoding: .utf8) + let occurrences = css.components(separatedBy: ".header { padding: 10px; }").count - 1 + #expect(occurrences == 1) // Only appears once despite 3 pages + } + + @Test func collectsFromNestedComponents() async throws { + struct InnerComponent: View, StyleModifier { + var style: String { ".inner { margin: 5px; }" } + var body: some View { Text("Inner") } + } + + struct OuterComponent: View, StyleModifier { + var style: String { ".outer { padding: 10px; }" } + var body: some View { InnerComponent() } + } + + let baseCSSURL = rootURL.appending(path: "base.css") + try "".write(to: baseCSSURL, atomically: true, encoding: .utf8) + + let sitemap: Sitemap = ["index.html": OuterComponent()] + try await renderSitemap( + sitemap, + to: rootURL, + baseCSS: baseCSSURL, + stylesheet: "output.css" + ) + + let css = try String(contentsOf: rootURL.appending(path: "output.css"), encoding: .utf8) + #expect(css.contains(".outer { padding: 10px; }")) + #expect(css.contains(".inner { margin: 5px; }")) + } + + @Test func handlesMultiplePages() async throws { + struct PageA: View, StyleModifier { + var style: String { ".page-a { color: blue; }" } + var body: some View { Text("Page A") } + } + + struct PageB: View, StyleModifier { + var style: String { ".page-b { color: green; }" } + var body: some View { Text("Page B") } + } + + let baseCSSURL = rootURL.appending(path: "base.css") + try "".write(to: baseCSSURL, atomically: true, encoding: .utf8) + + let sitemap: Sitemap = [ + "a.html": PageA(), + "b.html": PageB() + ] + + try await renderSitemap( + sitemap, + to: rootURL, + baseCSS: baseCSSURL, + stylesheet: "output.css" + ) + + let css = try String(contentsOf: rootURL.appending(path: "output.css"), encoding: .utf8) + #expect(css.contains(".page-a { color: blue; }")) + #expect(css.contains(".page-b { color: green; }")) + } + + @Test func doesNotCollectCSSWithoutConfiguration() async throws { + struct TestComponent: View, StyleModifier { + var style: String { ".test { color: red; }" } + var body: some View { Text("Test") } + } + + let sitemap: Sitemap = ["index.html": TestComponent()] + try renderSitemap(sitemap, to: rootURL) + + // HTML should be rendered + let html = try String(contentsOf: rootURL.appending(path: "index.html"), encoding: .utf8) + #expect(html.contains("Test")) + + // CSS file should not exist + let cssURL = rootURL.appending(path: "output.css") + #expect(!FileManager.default.fileExists(atPath: cssURL.path(percentEncoded: false))) + } +} diff --git a/Tests/SlipstreamTests/Rendering/RenderStylesTests.swift b/Tests/SlipstreamTests/Rendering/RenderStylesTests.swift new file mode 100644 index 00000000..1f08a64d --- /dev/null +++ b/Tests/SlipstreamTests/Rendering/RenderStylesTests.swift @@ -0,0 +1,344 @@ +import Foundation +import Testing + +@testable import Slipstream + +final class RenderStylesTests { + + let rootURL: URL + + init() throws { + self.rootURL = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + + try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) + } + + deinit { + try? FileManager.default.removeItem(at: rootURL) + } + + @Test func combinesBaseCSSWithNoComponents() async throws { + let baseCSSURL = rootURL.appending(path: "base.css") + let outputURL = rootURL.appending(path: "output.css") + + try """ + body { + margin: 0; + padding: 0; + } + """.write(to: baseCSSURL, atomically: true, encoding: .utf8) + + try renderStyles(from: [], baseCSS: baseCSSURL, to: outputURL) + + let output = try String(contentsOf: outputURL, encoding: .utf8) + let expected = "body {\n margin: 0;\n padding: 0;\n}\n\n@layer components {\n}\n" + #expect(output == expected) + } + + @Test func combinesBaseCSSWithSingleComponent() async throws { + struct TestComponent: StyleModifier { + var style: String { +""" +.test-component { + color: red; +} +""" + } + var componentName: String { "TestComponent" } + } + + let baseCSSURL = rootURL.appending(path: "base.css") + let outputURL = rootURL.appending(path: "output.css") + + try "/* Base CSS */".write(to: baseCSSURL, atomically: true, encoding: .utf8) + + try renderStyles(from: [TestComponent()], baseCSS: baseCSSURL, to: outputURL) + + let output = try String(contentsOf: outputURL, encoding: .utf8) + let expected = "/* Base CSS */\n\n@layer components {\n /* TestComponent */\n .test-component {\n color: red;\n }\n\n}\n" + #expect(output == expected) + } + + @Test func combinesBaseCSSWithMultipleComponents() async throws { + struct ComponentA: StyleModifier { + var style: String { +""" +.component-a { + background: blue; +} +""" + } + var componentName: String { "ComponentA" } + } + + struct ComponentB: StyleModifier { + var style: String { +""" +.component-b { + font-size: 16px; +} +""" + } + var componentName: String { "ComponentB" } + } + + let baseCSSURL = rootURL.appending(path: "base.css") + let outputURL = rootURL.appending(path: "output.css") + + try "/* Base */".write(to: baseCSSURL, atomically: true, encoding: .utf8) + + try renderStyles(from: [ComponentA(), ComponentB()], baseCSS: baseCSSURL, to: outputURL) + + let output = try String(contentsOf: outputURL, encoding: .utf8) + let expected = "/* Base */\n\n@layer components {\n /* ComponentA */\n .component-a {\n background: blue;\n }\n\n /* ComponentB */\n .component-b {\n font-size: 16px;\n }\n\n}\n" + #expect(output == expected) + } + + @Test func createsOutputDirectoryIfMissing() async throws { + let baseCSSURL = rootURL.appending(path: "base.css") + let outputURL = rootURL.appending(path: "nested/directory/output.css") + + try "/* Base */".write(to: baseCSSURL, atomically: true, encoding: .utf8) + + try renderStyles(from: [], baseCSS: baseCSSURL, to: outputURL) + + let output = try String(contentsOf: outputURL, encoding: .utf8) + let expected = "/* Base */\n\n@layer components {\n}\n" + #expect(output == expected) + } + + @Test func throwsErrorForMissingBaseCSS() async throws { + let baseCSSURL = rootURL.appending(path: "nonexistent.css") + let outputURL = rootURL.appending(path: "output.css") + + #expect(throws: Error.self) { + try renderStyles(from: [], baseCSS: baseCSSURL, to: outputURL) + } + } + + @Test func usesDefaultComponentName() async throws { + struct MyCustomComponent: StyleModifier { + var style: String { + ".custom { color: green; }" + } + } + + let baseCSSURL = rootURL.appending(path: "base.css") + let outputURL = rootURL.appending(path: "output.css") + + try "".write(to: baseCSSURL, atomically: true, encoding: .utf8) + + try renderStyles(from: [MyCustomComponent()], baseCSS: baseCSSURL, to: outputURL) + + let output = try String(contentsOf: outputURL, encoding: .utf8) + #expect(output.contains("/* MyCustomComponent */")) + } + + @Test func handlesEmptyComponentCSS() async throws { + struct EmptyComponent: StyleModifier { + var style: String { "" } + var componentName: String { "EmptyComponent" } + } + + let baseCSSURL = rootURL.appending(path: "base.css") + let outputURL = rootURL.appending(path: "output.css") + + try "/* Base */".write(to: baseCSSURL, atomically: true, encoding: .utf8) + + try renderStyles(from: [EmptyComponent()], baseCSS: baseCSSURL, to: outputURL) + + let output = try String(contentsOf: outputURL, encoding: .utf8) + let expected = "/* Base */\n\n@layer components {\n /* EmptyComponent */\n\n\n}\n" + #expect(output == expected) + } + + @Test func handlesEmptyBaseCSS() async throws { + struct TestComponent: StyleModifier { + var style: String { ".test { color: red; }" } + var componentName: String { "TestComponent" } + } + + let baseCSSURL = rootURL.appending(path: "base.css") + let outputURL = rootURL.appending(path: "output.css") + + try "".write(to: baseCSSURL, atomically: true, encoding: .utf8) + + try renderStyles(from: [TestComponent()], baseCSS: baseCSSURL, to: outputURL) + + let output = try String(contentsOf: outputURL, encoding: .utf8) + let expected = "\n\n@layer components {\n /* TestComponent */\n .test { color: red; }\n\n}\n" + #expect(output == expected) + } + + @Test func withoutComponentLayerWrapsSingleComponent() async throws { + struct TestComponent: StyleModifier { + var style: String { +""" +.test-component { + color: blue; +} +""" + } + var componentName: String { "TestComponent" } + } + + let baseCSSURL = rootURL.appending(path: "base.css") + let outputURL = rootURL.appending(path: "output.css") + + try "/* Base CSS */".write(to: baseCSSURL, atomically: true, encoding: .utf8) + + try renderStyles(from: [TestComponent()], baseCSS: baseCSSURL, to: outputURL, useComponentLayer: false) + + let output = try String(contentsOf: outputURL, encoding: .utf8) + let expected = "/* Base CSS */\n\n/* TestComponent */\n.test-component {\n color: blue;\n}\n\n" + #expect(output == expected) + } + + @Test func withoutComponentLayerWrapsMultipleComponents() async throws { + struct ComponentA: StyleModifier { + var style: String { +""" +.component-a { + background: green; +} +""" + } + var componentName: String { "ComponentA" } + } + + struct ComponentB: StyleModifier { + var style: String { +""" +.component-b { + font-size: 18px; +} +""" + } + var componentName: String { "ComponentB" } + } + + let baseCSSURL = rootURL.appending(path: "base.css") + let outputURL = rootURL.appending(path: "output.css") + + try "/* Base */".write(to: baseCSSURL, atomically: true, encoding: .utf8) + + try renderStyles(from: [ComponentA(), ComponentB()], baseCSS: baseCSSURL, to: outputURL, useComponentLayer: false) + + let output = try String(contentsOf: outputURL, encoding: .utf8) + let expected = "/* Base */\n\n/* ComponentA */\n.component-a {\n background: green;\n}\n\n/* ComponentB */\n.component-b {\n font-size: 18px;\n}\n\n" + #expect(output == expected) + } + + @Test func withoutComponentLayerHandlesEmptyComponents() async throws { + let baseCSSURL = rootURL.appending(path: "base.css") + let outputURL = rootURL.appending(path: "output.css") + + try "/* Base */".write(to: baseCSSURL, atomically: true, encoding: .utf8) + + try renderStyles(from: [], baseCSS: baseCSSURL, to: outputURL, useComponentLayer: false) + + let output = try String(contentsOf: outputURL, encoding: .utf8) + let expected = "/* Base */\n\n" + #expect(output == expected) + } + + @Test func deduplicatesComponentsWithIdenticalCSS() async throws { + struct Header: StyleModifier { + var style: String { ".header { background: blue; }" } + var componentName: String { "Header" } + } + + let baseCSSURL = rootURL.appending(path: "base.css") + let outputURL = rootURL.appending(path: "output.css") + + try "/* Base */".write(to: baseCSSURL, atomically: true, encoding: .utf8) + + // Pass same component multiple times (simulating shared header across pages) + try renderStyles(from: [Header(), Header(), Header()], baseCSS: baseCSSURL, to: outputURL) + + let output = try String(contentsOf: outputURL, encoding: .utf8) + // Should only appear once, not three times + let expected = "/* Base */\n\n@layer components {\n /* Header */\n .header { background: blue; }\n\n}\n" + #expect(output == expected) + } + + @Test func deduplicatesPreservesFirstOccurrence() async throws { + struct ComponentA: StyleModifier { + var style: String { ".shared { color: red; }" } + var componentName: String { "ComponentA" } + } + + struct ComponentB: StyleModifier { + var style: String { ".shared { color: red; }" } // Same CSS + var componentName: String { "ComponentB" } + } + + let baseCSSURL = rootURL.appending(path: "base.css") + let outputURL = rootURL.appending(path: "output.css") + + try "/* Base */".write(to: baseCSSURL, atomically: true, encoding: .utf8) + + // ComponentA appears first, so its name should be used + try renderStyles(from: [ComponentA(), ComponentB()], baseCSS: baseCSSURL, to: outputURL) + + let output = try String(contentsOf: outputURL, encoding: .utf8) + // Should use "ComponentA" comment, not "ComponentB" + #expect(output.contains("/* ComponentA */")) + #expect(!output.contains("/* ComponentB */")) + + // CSS should only appear once + let cssOccurrences = output.components(separatedBy: ".shared { color: red; }").count - 1 + #expect(cssOccurrences == 1) + } + + @Test func deduplicationWorksWithoutComponentLayer() async throws { + struct Header: StyleModifier { + var style: String { ".header { padding: 20px; }" } + var componentName: String { "Header" } + } + + let baseCSSURL = rootURL.appending(path: "base.css") + let outputURL = rootURL.appending(path: "output.css") + + try "/* Base */".write(to: baseCSSURL, atomically: true, encoding: .utf8) + + try renderStyles(from: [Header(), Header()], baseCSS: baseCSSURL, to: outputURL, useComponentLayer: false) + + let output = try String(contentsOf: outputURL, encoding: .utf8) + // Should only appear once without @layer wrapper + let expected = "/* Base */\n\n/* Header */\n.header { padding: 20px; }\n\n" + #expect(output == expected) + } + + @Test func deduplicationKeepsUniqueComponents() async throws { + struct Header: StyleModifier { + var style: String { ".header { color: blue; }" } + var componentName: String { "Header" } + } + + struct Footer: StyleModifier { + var style: String { ".footer { color: gray; }" } + var componentName: String { "Footer" } + } + + let baseCSSURL = rootURL.appending(path: "base.css") + let outputURL = rootURL.appending(path: "output.css") + + try "/* Base */".write(to: baseCSSURL, atomically: true, encoding: .utf8) + + // Pass Header twice, Footer once + try renderStyles(from: [Header(), Footer(), Header()], baseCSS: baseCSSURL, to: outputURL) + + let output = try String(contentsOf: outputURL, encoding: .utf8) + + // Both should appear once + #expect(output.contains("/* Header */")) + #expect(output.contains("/* Footer */")) + #expect(output.contains(".header { color: blue; }")) + #expect(output.contains(".footer { color: gray; }")) + + // Header CSS should only appear once despite being passed twice + let headerOccurrences = output.components(separatedBy: ".header { color: blue; }").count - 1 + #expect(headerOccurrences == 1) + } +} diff --git a/Tests/SlipstreamTests/Rendering/StyleCollectionTests.swift b/Tests/SlipstreamTests/Rendering/StyleCollectionTests.swift new file mode 100644 index 00000000..0e7c287f --- /dev/null +++ b/Tests/SlipstreamTests/Rendering/StyleCollectionTests.swift @@ -0,0 +1,279 @@ +import Foundation +import Testing +import SwiftSoup + +@testable import Slipstream + +/// Tests for the automatic CSS style collection system. +/// +/// These tests verify: +/// 1. The `style()` method automatically collects CSS from views via `body` traversal +/// 2. Style collection works independently of `render()` implementation +/// 3. CSS collection handles nested components and runtime values correctly +/// 4. Deduplication logic works as expected +final class StyleCollectionTests { + + // MARK: - Test Helpers + + /// Collects CSS components using the style() method + private func renderAndCollectCSS(view: any View) async throws -> [any StyleModifier] { + let styleContext = StyleContext() + var environment = EnvironmentValues() + environment.styleContext = styleContext + + try await view.style(environment: environment) + + return await styleContext.allComponents + } + + // MARK: - Test Components + + struct SimpleComponent: View, StyleModifier { + var style: String { ".simple { color: red; }" } + var componentName: String { "SimpleComponent" } + var body: some View { Text("Simple") } + } + + struct ComponentWithRuntimeValue: View, StyleModifier { + let id: String + let count: Int + + var style: String { + """ + /* Component[\(id)] with \(count) items */ + #\(id)-item { display: block; } + """ + } + + var componentName: String { "Component[\(id)]" } + + var body: some View { + Div { + Text("Runtime: \(id)") + } + } + } + + /// Demonstrates that style() method works even with custom render() + struct ComponentWithCustomRender: View, StyleModifier { + typealias Content = Never + + var style: String { ".custom { color: blue; }" } + var componentName: String { "CustomRender" } + + // ✅ Custom render() doesn't affect style() - CSS is still collected! + func render(_ container: Element, environment: EnvironmentValues) throws { + let element = try container.appendElement("custom") + try element.appendText("Custom rendered") + } + } + + /// Shows that style() method collects CSS even with custom render() + struct ComponentWithManualRegistration: View, StyleModifier { + typealias Content = Never + + var style: String { ".manual { color: green; }" } + var componentName: String { "ManualRegistration" } + + // Custom render() doesn't affect style() collection + func render(_ container: Element, environment: EnvironmentValues) throws { + let element = try container.appendElement("manual") + try element.appendText("Manually registered") + } + } + + struct PageWithNestedComponents: View { + var body: some View { + Div { + SimpleComponent() + ComponentWithRuntimeValue(id: "header", count: 3) + ComponentWithRuntimeValue(id: "footer", count: 2) + } + } + } + + // MARK: - Tests: Automatic Registration (Works) + + @Test("Automatic registration works for simple component with body") + func automaticRegistrationForBodyComponent() async throws { + let components = try await renderAndCollectCSS(view: SimpleComponent()) + + #expect(components.count == 1) + #expect(components[0].componentName == "SimpleComponent") + #expect(components[0].style.contains(".simple { color: red; }")) + } + + @Test("Automatic registration captures runtime values") + func automaticRegistrationWithRuntimeValues() async throws { + let component = ComponentWithRuntimeValue(id: "test-tabs", count: 5) + let components = try await renderAndCollectCSS(view: component) + + #expect(components.count == 1) + #expect(components[0].componentName == "Component[test-tabs]") + #expect(components[0].style.contains("#test-tabs-item")) + #expect(components[0].style.contains("with 5 items")) + } + + @Test("Automatic registration works for nested components") + func automaticRegistrationForNestedComponents() async throws { + let page = PageWithNestedComponents() + let components = try await renderAndCollectCSS(view: page) + + // Should automatically find all 3 nested components + #expect(components.count == 3) + + let names = components.map { $0.componentName } + #expect(names.contains("SimpleComponent")) + #expect(names.contains("Component[header]")) + #expect(names.contains("Component[footer]")) + + // Verify runtime values are correct + let headerComponent = components.first { $0.componentName == "Component[header]" } + #expect(headerComponent?.style.contains("with 3 items") == true) + + let footerComponent = components.first { $0.componentName == "Component[footer]" } + #expect(footerComponent?.style.contains("with 2 items") == true) + } + + // MARK: - Tests: style() Works With Custom Render + + @Test("style() method collects CSS even with custom render()") + func styleMethodWorksWithCustomRender() async throws { + let component = ComponentWithCustomRender() + let components = try await renderAndCollectCSS(view: component) + + // ✅ style() method is independent of render() + // Result: Component IS collected even with custom render() + #expect(components.count == 1) + #expect(components[0].componentName == "CustomRender") + } + + @Test("style() method works for all components with custom render()") + func styleMethodWorksForAllCustomRenderComponents() async throws { + let component = ComponentWithManualRegistration() + let components = try await renderAndCollectCSS(view: component) + + // ✅ style() method collects CSS regardless of custom render() + #expect(components.count == 1) + #expect(components[0].componentName == "ManualRegistration") + } + + // MARK: - Tests: Deduplication + + @Test("CSS deduplication works (first occurrence wins)") + func cssDeduplication() async throws { + struct DupeA: View, StyleModifier { + var style: String { ".dupe { margin: 10px; }" } + var componentName: String { "DupeA" } + var body: some View { Text("A") } + } + + struct DupeB: View, StyleModifier { + var style: String { ".dupe { margin: 10px; }" } + var componentName: String { "DupeB" } + var body: some View { Text("B") } + } + + struct PageWithDupes: View { + var body: some View { + Div { + DupeA() + DupeB() + } + } + } + + let page = PageWithDupes() + let components = try await renderAndCollectCSS(view: page) + + // Both components collected + #expect(components.count == 2) + + // Deduplication by CSS content (simulating renderStyles behavior) + var seenCSS = Set() + let uniqueComponents = components.filter { component in + seenCSS.insert(component.style).inserted + } + + // Only first occurrence remains after deduplication + #expect(uniqueComponents.count == 1) + #expect(uniqueComponents[0].componentName == "DupeA") + } + + // MARK: - Tests: View Wrapper Traversal (Regression Tests) + + @Test("style() traverses through AttributeModifierView") + func styleTraversesThroughAttributeModifier() async throws { + // This is a regression test for the bug where AttributeModifierView + // was missing a style() method, causing CSS collection to stop + // at any view with attributes (like .language(), .id(), etc.) + + struct NestedInAttribute: View { + var body: some View { + Div { + SimpleComponent() + } + .language("en") // This wraps in AttributeModifierView + } + } + + let components = try await renderAndCollectCSS(view: NestedInAttribute()) + + // SimpleComponent should be found even though it's inside AttributeModifierView + #expect(components.count == 1) + #expect(components[0].componentName == "SimpleComponent") + } + + @Test("style() traverses through multiple nested wrapper views") + func styleTraversesThroughMultipleWrappers() async throws { + // Integration test: StyleModifier deeply nested in multiple wrapper types + // should still be collected. This catches future bugs in any view type. + + struct DeeplyNested: View { + let showComponent: Bool = true + + var body: some View { + Div { + if showComponent { + Div { + SimpleComponent() + } + .id("inner") + .language("en") + } + } + .id("outer") + } + } + + let components = try await renderAndCollectCSS(view: DeeplyNested()) + + // SimpleComponent should be found through: + // DeeplyNested -> Div (AttributeModifier[id]) -> ConditionalView -> + // Div (AttributeModifier[id]) -> AttributeModifier[language] -> SimpleComponent + #expect(components.count == 1) + #expect(components[0].componentName == "SimpleComponent") + } + + @Test("style() traverses through ForEach with attributes") + func styleTraversesThroughForEachWithAttributes() async throws { + struct ListWithComponents: View { + var body: some View { + Div { + ForEach(["a", "b"], id: \.self) { item in + ComponentWithRuntimeValue(id: item, count: 1) + } + } + .id("list") + } + } + + let components = try await renderAndCollectCSS(view: ListWithComponents()) + + // Both components from ForEach should be collected + #expect(components.count == 2) + let names = components.map { $0.componentName } + #expect(names.contains("Component[a]")) + #expect(names.contains("Component[b]")) + } +} diff --git a/Tests/SlipstreamTests/Rendering/StyleModifierTests.swift b/Tests/SlipstreamTests/Rendering/StyleModifierTests.swift new file mode 100644 index 00000000..ca840570 --- /dev/null +++ b/Tests/SlipstreamTests/Rendering/StyleModifierTests.swift @@ -0,0 +1,75 @@ +import Testing + +import Slipstream + +struct StyleModifierTests { + + @Test func defaultComponentNameUsesTypeName() async throws { + struct MyTestComponent: StyleModifier { + var style: String { ".test { color: blue; }" } + } + + let component = MyTestComponent() + #expect(component.componentName == "MyTestComponent") + } + + @Test func defaultComponentNameWithGenericType() async throws { + struct GenericComponent: StyleModifier { + var style: String { ".generic { display: block; }" } + let value: T + } + + let component = GenericComponent(value: "test") + #expect(component.componentName == "GenericComponent") + } + + @Test func customComponentNameOverridesDefault() async throws { + struct CustomNameComponent: StyleModifier { + var style: String { ".custom { margin: 0; }" } + var componentName: String { "MyCustomName" } + } + + let component = CustomNameComponent() + #expect(component.componentName == "MyCustomName") + } + + @Test func stylePropertyIsRequired() async throws { + struct MinimalComponent: StyleModifier { + var style: String { + """ + .minimal { + padding: 10px; + } + """ + } + } + + let component = MinimalComponent() + #expect(component.style.contains(".minimal")) + #expect(component.style.contains("padding: 10px")) + } + + @Test func multipleInstancesHaveSameComponentName() async throws { + struct ReusableComponent: StyleModifier { + var style: String { ".reusable { width: 100%; }" } + let id: Int + } + + let component1 = ReusableComponent(id: 1) + let component2 = ReusableComponent(id: 2) + + #expect(component1.componentName == component2.componentName) + #expect(component1.componentName == "ReusableComponent") + } + + @Test func nestedTypeComponentName() async throws { + struct OuterComponent { + struct InnerComponent: StyleModifier { + var style: String { ".inner { color: red; }" } + } + } + + let component = OuterComponent.InnerComponent() + #expect(component.componentName == "InnerComponent") + } +}