From 46c205a4071da9792fe6cbb96648cb3b773012fa Mon Sep 17 00:00:00 2001 From: csjones Date: Tue, 26 Aug 2025 01:31:38 -0700 Subject: [PATCH 1/7] feat: add CSS rendering system with component-specific style support --- .../Rendering/HasComponentCSS.swift | 44 +++++ .../Slipstream/Rendering/RenderStyles.swift | 40 ++++ .../Rendering/HasComponentCSSTests.swift | 75 ++++++++ .../Rendering/RenderStylesTests.swift | 181 ++++++++++++++++++ 4 files changed, 340 insertions(+) create mode 100644 Sources/Slipstream/Rendering/HasComponentCSS.swift create mode 100644 Sources/Slipstream/Rendering/RenderStyles.swift create mode 100644 Tests/SlipstreamTests/Rendering/HasComponentCSSTests.swift create mode 100644 Tests/SlipstreamTests/Rendering/RenderStylesTests.swift diff --git a/Sources/Slipstream/Rendering/HasComponentCSS.swift b/Sources/Slipstream/Rendering/HasComponentCSS.swift new file mode 100644 index 00000000..3d20c6c3 --- /dev/null +++ b/Sources/Slipstream/Rendering/HasComponentCSS.swift @@ -0,0 +1,44 @@ +import Foundation + +/// 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, HasComponentCSS { +/// var componentCSS: 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 HasComponentCSS { + /// The CSS styles for this component instance. + var componentCSS: 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 HasComponentCSS { + /// 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/Rendering/RenderStyles.swift b/Sources/Slipstream/Rendering/RenderStyles.swift new file mode 100644 index 00000000..e714c312 --- /dev/null +++ b/Sources/Slipstream/Rendering/RenderStyles.swift @@ -0,0 +1,40 @@ +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 `HasComponentCSS`, then writes the combined result to an output file. +/// +/// - Parameter components: Array of component instances that conform to `HasComponentCSS`. +/// - Parameter baseCSS: URL to the base CSS file to read. +/// - Parameter output: URL where the combined CSS should be written. +/// - 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, *) +public func renderStyles( + from components: [any HasComponentCSS], + baseCSS: URL, + to output: URL +) throws { + var cssContent = "" + + // Read base CSS file + let baseContent = try String(contentsOf: baseCSS, encoding: .utf8) + cssContent += baseContent + cssContent += "\n\n" + + // Add component-specific styles + cssContent += "/* Component-specific styles */\n" + + for component in components { + cssContent += "/* \(component.componentName) */\n" + cssContent += component.componentCSS + cssContent += "\n\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/Tests/SlipstreamTests/Rendering/HasComponentCSSTests.swift b/Tests/SlipstreamTests/Rendering/HasComponentCSSTests.swift new file mode 100644 index 00000000..eefcb4ac --- /dev/null +++ b/Tests/SlipstreamTests/Rendering/HasComponentCSSTests.swift @@ -0,0 +1,75 @@ +import Testing + +import Slipstream + +struct HasComponentCSSTests { + + @Test func defaultComponentNameUsesTypeName() async throws { + struct MyTestComponent: HasComponentCSS { + var componentCSS: String { ".test { color: blue; }" } + } + + let component = MyTestComponent() + #expect(component.componentName == "MyTestComponent") + } + + @Test func defaultComponentNameWithGenericType() async throws { + struct GenericComponent: HasComponentCSS { + var componentCSS: String { ".generic { display: block; }" } + let value: T + } + + let component = GenericComponent(value: "test") + #expect(component.componentName == "GenericComponent") + } + + @Test func customComponentNameOverridesDefault() async throws { + struct CustomNameComponent: HasComponentCSS { + var componentCSS: String { ".custom { margin: 0; }" } + var componentName: String { "MyCustomName" } + } + + let component = CustomNameComponent() + #expect(component.componentName == "MyCustomName") + } + + @Test func componentCSSPropertyIsRequired() async throws { + struct MinimalComponent: HasComponentCSS { + var componentCSS: String { + """ + .minimal { + padding: 10px; + } + """ + } + } + + let component = MinimalComponent() + #expect(component.componentCSS.contains(".minimal")) + #expect(component.componentCSS.contains("padding: 10px")) + } + + @Test func multipleInstancesHaveSameComponentName() async throws { + struct ReusableComponent: HasComponentCSS { + var componentCSS: 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: HasComponentCSS { + var componentCSS: String { ".inner { color: red; }" } + } + } + + let component = OuterComponent.InnerComponent() + #expect(component.componentName == "InnerComponent") + } +} diff --git a/Tests/SlipstreamTests/Rendering/RenderStylesTests.swift b/Tests/SlipstreamTests/Rendering/RenderStylesTests.swift new file mode 100644 index 00000000..97b2bb58 --- /dev/null +++ b/Tests/SlipstreamTests/Rendering/RenderStylesTests.swift @@ -0,0 +1,181 @@ +import Foundation +import Testing + +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) + #expect(output == """ +body { + margin: 0; + padding: 0; +} + +/* Component-specific styles */\n +""") + } + + @Test func combinesBaseCSSWithSingleComponent() async throws { + struct TestComponent: HasComponentCSS { + var componentCSS: 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/* Component-specific styles */\n/* TestComponent */\n.test-component {\n color: red;\n}\n\n" + #expect(output == expected) + } + + @Test func combinesBaseCSSWithMultipleComponents() async throws { + struct ComponentA: HasComponentCSS { + var componentCSS: String { +""" +.component-a { + background: blue; +} +""" + } + var componentName: String { "ComponentA" } + } + + struct ComponentB: HasComponentCSS { + var componentCSS: 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/* Component-specific styles */\n/* ComponentA */\n.component-a {\n background: blue;\n}\n\n/* ComponentB */\n.component-b {\n font-size: 16px;\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) + #expect(output == """ +/* Base */ + +/* Component-specific styles */\n +""") + } + + @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: HasComponentCSS { + var componentCSS: 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: HasComponentCSS { + var componentCSS: 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/* Component-specific styles */\n/* EmptyComponent */\n\n\n" + #expect(output == expected) + } + + @Test func handlesEmptyBaseCSS() async throws { + struct TestComponent: HasComponentCSS { + var componentCSS: 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/* Component-specific styles */\n/* TestComponent */\n.test { color: red; }\n\n" + #expect(output == expected) + } +} From a7db7afa3f848aec81cc2be204e4702c179df881 Mon Sep 17 00:00:00 2001 From: csjones Date: Fri, 7 Nov 2025 16:37:25 -0800 Subject: [PATCH 2/7] feat: add Tailwind layer support to renderStyles and CSS deduplication - Wrap component styles in `@layer components` by default for proper Tailwind CSS v3 cascade ordering - Add `useComponentLayer` parameter to support Tailwind CSS v4's automatic ordering when set to false - Automatically deduplicate components with identical CSS content (first occurrence wins) --- .../Slipstream/Rendering/RenderStyles.swift | 41 +++- .../Rendering/RenderStylesTests.swift | 197 ++++++++++++++++-- 2 files changed, 216 insertions(+), 22 deletions(-) diff --git a/Sources/Slipstream/Rendering/RenderStyles.swift b/Sources/Slipstream/Rendering/RenderStyles.swift index e714c312..99a36250 100644 --- a/Sources/Slipstream/Rendering/RenderStyles.swift +++ b/Sources/Slipstream/Rendering/RenderStyles.swift @@ -4,16 +4,24 @@ import Foundation /// /// This function reads a base CSS file and appends CSS from all component instances /// conforming to `HasComponentCSS`, 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 `HasComponentCSS`. /// - 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, *) public func renderStyles( from components: [any HasComponentCSS], baseCSS: URL, - to output: URL + to output: URL, + useComponentLayer: Bool = true ) throws { var cssContent = "" @@ -22,15 +30,38 @@ public func renderStyles( 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.componentCSS).inserted + } + // Add component-specific styles - cssContent += "/* Component-specific styles */\n" + if useComponentLayer { + cssContent += "@layer components {\n" + } - for component in components { - cssContent += "/* \(component.componentName) */\n" - cssContent += component.componentCSS + for component in uniqueComponents { + let indent = useComponentLayer ? " " : "" + cssContent += "\(indent)/* \(component.componentName) */\n" + + // Conditionally indent each line of component CSS + if useComponentLayer { + let indentedCSS = component.componentCSS + .split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.isEmpty ? "" : " \($0)" } + .joined(separator: "\n") + cssContent += indentedCSS + } else { + cssContent += component.componentCSS + } cssContent += "\n\n" } + if useComponentLayer { + cssContent += "}\n" + } + // Ensure output directory exists let outputDir = output.deletingLastPathComponent() try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true) diff --git a/Tests/SlipstreamTests/Rendering/RenderStylesTests.swift b/Tests/SlipstreamTests/Rendering/RenderStylesTests.swift index 97b2bb58..03b4ae69 100644 --- a/Tests/SlipstreamTests/Rendering/RenderStylesTests.swift +++ b/Tests/SlipstreamTests/Rendering/RenderStylesTests.swift @@ -31,14 +31,8 @@ final class RenderStylesTests { try renderStyles(from: [], baseCSS: baseCSSURL, to: outputURL) let output = try String(contentsOf: outputURL, encoding: .utf8) - #expect(output == """ -body { - margin: 0; - padding: 0; -} - -/* Component-specific styles */\n -""") + let expected = "body {\n margin: 0;\n padding: 0;\n}\n\n@layer components {\n}\n" + #expect(output == expected) } @Test func combinesBaseCSSWithSingleComponent() async throws { @@ -61,7 +55,7 @@ body { try renderStyles(from: [TestComponent()], baseCSS: baseCSSURL, to: outputURL) let output = try String(contentsOf: outputURL, encoding: .utf8) - let expected = "/* Base CSS */\n\n/* Component-specific styles */\n/* TestComponent */\n.test-component {\n color: red;\n}\n\n" + let expected = "/* Base CSS */\n\n@layer components {\n /* TestComponent */\n .test-component {\n color: red;\n }\n\n}\n" #expect(output == expected) } @@ -96,7 +90,7 @@ body { try renderStyles(from: [ComponentA(), ComponentB()], baseCSS: baseCSSURL, to: outputURL) let output = try String(contentsOf: outputURL, encoding: .utf8) - let expected = "/* Base */\n\n/* Component-specific styles */\n/* ComponentA */\n.component-a {\n background: blue;\n}\n\n/* ComponentB */\n.component-b {\n font-size: 16px;\n}\n\n" + 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) } @@ -109,11 +103,8 @@ body { try renderStyles(from: [], baseCSS: baseCSSURL, to: outputURL) let output = try String(contentsOf: outputURL, encoding: .utf8) - #expect(output == """ -/* Base */ - -/* Component-specific styles */\n -""") + let expected = "/* Base */\n\n@layer components {\n}\n" + #expect(output == expected) } @Test func throwsErrorForMissingBaseCSS() async throws { @@ -157,7 +148,7 @@ body { try renderStyles(from: [EmptyComponent()], baseCSS: baseCSSURL, to: outputURL) let output = try String(contentsOf: outputURL, encoding: .utf8) - let expected = "/* Base */\n\n/* Component-specific styles */\n/* EmptyComponent */\n\n\n" + let expected = "/* Base */\n\n@layer components {\n /* EmptyComponent */\n\n\n}\n" #expect(output == expected) } @@ -175,7 +166,179 @@ body { try renderStyles(from: [TestComponent()], baseCSS: baseCSSURL, to: outputURL) let output = try String(contentsOf: outputURL, encoding: .utf8) - let expected = "\n\n/* Component-specific styles */\n/* TestComponent */\n.test { color: red; }\n\n" + 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: HasComponentCSS { + var componentCSS: 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: HasComponentCSS { + var componentCSS: String { +""" +.component-a { + background: green; +} +""" + } + var componentName: String { "ComponentA" } + } + + struct ComponentB: HasComponentCSS { + var componentCSS: 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: HasComponentCSS { + var componentCSS: 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: HasComponentCSS { + var componentCSS: String { ".shared { color: red; }" } + var componentName: String { "ComponentA" } + } + + struct ComponentB: HasComponentCSS { + var componentCSS: 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: HasComponentCSS { + var componentCSS: 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: HasComponentCSS { + var componentCSS: String { ".header { color: blue; }" } + var componentName: String { "Header" } + } + + struct Footer: HasComponentCSS { + var componentCSS: 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) + } } From e31d7e59b580f05c8713a6f6c4563060dbf44711 Mon Sep 17 00:00:00 2001 From: csjones Date: Sat, 8 Nov 2025 22:18:47 -0800 Subject: [PATCH 3/7] feat: add automatic CSS collection during sitemap rendering - Introduced ComponentLayerContext to automatically collect HasComponentCSS components during view rendering - Extended renderSitemap with optional cssConfiguration parameter to generate combined CSS output - Changed renderStyles visibility to internal as it's now called automatically by renderSitemap --- .../DataAndStorage/EnvironmentValues.swift | 17 ++ .../Rendering/ComponentLayerContext.swift | 27 ++++ .../Rendering/HasComponentCSS.swift | 19 +++ .../Slipstream/Rendering/RenderSitemap.swift | 53 ++++-- .../Slipstream/Rendering/RenderStyles.swift | 2 +- .../Rendering/RenderSitemapCSSTests.swift | 152 ++++++++++++++++++ .../Rendering/RenderStylesTests.swift | 2 +- 7 files changed, 261 insertions(+), 11 deletions(-) create mode 100644 Sources/Slipstream/Rendering/ComponentLayerContext.swift create mode 100644 Tests/SlipstreamTests/Rendering/RenderSitemapCSSTests.swift diff --git a/Sources/Slipstream/Fundamentals/DataAndStorage/EnvironmentValues.swift b/Sources/Slipstream/Fundamentals/DataAndStorage/EnvironmentValues.swift index aded326d..10d573be 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: - Component Layer Context + +private struct ComponentLayerContextKey: EnvironmentKey { + static let defaultValue: ComponentLayerContext? = nil +} + +extension EnvironmentValues { + /// The context for collecting CSS components during rendering. + /// + /// This is used internally by the rendering system to automatically + /// collect components that conform to `HasComponentCSS`. + var componentLayerContext: ComponentLayerContext? { + get { self[ComponentLayerContextKey.self] } + set { self[ComponentLayerContextKey.self] = newValue } + } +} diff --git a/Sources/Slipstream/Rendering/ComponentLayerContext.swift b/Sources/Slipstream/Rendering/ComponentLayerContext.swift new file mode 100644 index 00000000..f71a2b68 --- /dev/null +++ b/Sources/Slipstream/Rendering/ComponentLayerContext.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Context for collecting CSS components during view rendering. +/// +/// This context is used internally by the rendering system to automatically +/// collect components that conform to `HasComponentCSS` while traversing the +/// view hierarchy. The collected components are then used to generate CSS +/// for the `@layer components` section in Tailwind CSS. +/// +/// This class is marked as `@unchecked Sendable` because it's only used +/// synchronously within a single rendering pass and is not shared across threads. +@available(iOS 17.0, macOS 14.0, *) +final class ComponentLayerContext: @unchecked Sendable { + private var components: [any HasComponentCSS] = [] + + /// Adds a component to the collection. + /// + /// - Parameter component: A component conforming to `HasComponentCSS`. + func add(_ component: any HasComponentCSS) { + components.append(component) + } + + /// All collected components. + var allComponents: [any HasComponentCSS] { + components + } +} diff --git a/Sources/Slipstream/Rendering/HasComponentCSS.swift b/Sources/Slipstream/Rendering/HasComponentCSS.swift index 3d20c6c3..7794bb42 100644 --- a/Sources/Slipstream/Rendering/HasComponentCSS.swift +++ b/Sources/Slipstream/Rendering/HasComponentCSS.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftSoup /// A protocol for components that provide CSS styles. /// @@ -42,3 +43,21 @@ public extension HasComponentCSS { String(describing: type(of: self)) } } + +// MARK: - Automatic Registration + +@available(iOS 17.0, macOS 14.0, *) +extension View where Self: HasComponentCSS { + /// Automatically registers this component's CSS when rendering. + /// + /// Views conforming to `HasComponentCSS` are automatically registered with + /// the component layer context during rendering, allowing CSS to be collected + /// without manual component list management. + public func render(_ container: Element, environment: EnvironmentValues) throws { + // Register this component's CSS with the collection context + environment.componentLayerContext?.add(self) + + // Continue normal rendering + try injectEnvironment(environment: environment).body.render(container, environment: environment) + } +} diff --git a/Sources/Slipstream/Rendering/RenderSitemap.swift b/Sources/Slipstream/Rendering/RenderSitemap.swift index 7ab65ff7..9b3ae685 100644 --- a/Sources/Slipstream/Rendering/RenderSitemap.swift +++ b/Sources/Slipstream/Rendering/RenderSitemap.swift @@ -1,21 +1,56 @@ import Foundation +import SwiftSoup /// Renders the given sitemap to a folder. /// /// - Parameter sitemap: A mapping of relative paths to Slipstream views. /// - Parameter folder: The root folder of the sitemap. +/// - Parameter cssConfiguration: Optional tuple specifying base CSS file and output location for generated component CSS. +/// When provided, components conforming to `HasComponentCSS` are automatically collected during rendering and their +/// CSS is combined with the base CSS file. The result is written to the specified output location. /// - 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, encoding: String.Encoding = .utf8) throws { - for (path, view) in sitemap.sorted(by: { $0.key < $1.key }) { - let output = try "\n" + renderHTML(view) - 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) +public func renderSitemap( + _ sitemap: Sitemap, + to folder: URL, + cssConfiguration: (baseCSS: URL, output: URL)? = nil, + encoding: String.Encoding = .utf8 +) throws { + // Create CSS collection context if CSS generation requested + var environment = EnvironmentValues() + let cssContext: ComponentLayerContext? + + if cssConfiguration != nil { + let context = ComponentLayerContext() + environment.componentLayerContext = context + cssContext = context + } else { + cssContext = nil + } + + // Render HTML pages (collecting CSS if context present) + 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) + } + + // Generate CSS file if requested + if let cssConfiguration = cssConfiguration, + let cssContext = cssContext { + try renderStyles( + from: cssContext.allComponents, + baseCSS: cssConfiguration.baseCSS, + to: cssConfiguration.output + ) } - try output.write(to: fileURL, atomically: true, encoding: encoding) - } } /// Renders a sitemap in parallel and returns the rendered pages. diff --git a/Sources/Slipstream/Rendering/RenderStyles.swift b/Sources/Slipstream/Rendering/RenderStyles.swift index 99a36250..8ec027ec 100644 --- a/Sources/Slipstream/Rendering/RenderStyles.swift +++ b/Sources/Slipstream/Rendering/RenderStyles.swift @@ -17,7 +17,7 @@ import Foundation /// 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, *) -public func renderStyles( +internal func renderStyles( from components: [any HasComponentCSS], baseCSS: URL, to output: URL, diff --git a/Tests/SlipstreamTests/Rendering/RenderSitemapCSSTests.swift b/Tests/SlipstreamTests/Rendering/RenderSitemapCSSTests.swift new file mode 100644 index 00000000..595bb498 --- /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, HasComponentCSS { + var componentCSS: String { ".test { color: red; }" } + var body: some View { Text("Test") } + } + + let baseCSSURL = rootURL.appending(path: "base.css") + let cssOutputURL = rootURL.appending(path: "output.css") + + try "/* Base */".write(to: baseCSSURL, atomically: true, encoding: .utf8) + + let sitemap: Sitemap = ["index.html": TestComponent()] + try renderSitemap( + sitemap, + to: rootURL, + cssConfiguration: (baseCSS: baseCSSURL, output: cssOutputURL) + ) + + let css = try String(contentsOf: cssOutputURL, 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, HasComponentCSS { + var componentCSS: String { ".header { padding: 10px; }" } + var body: some View { Text("Header") } + } + + let baseCSSURL = rootURL.appending(path: "base.css") + let cssOutputURL = rootURL.appending(path: "output.css") + try "".write(to: baseCSSURL, atomically: true, encoding: .utf8) + + let sitemap: Sitemap = [ + "page1.html": Header(), + "page2.html": Header(), + "page3.html": Header() + ] + + try renderSitemap( + sitemap, + to: rootURL, + cssConfiguration: (baseCSS: baseCSSURL, output: cssOutputURL) + ) + + let css = try String(contentsOf: cssOutputURL, 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, HasComponentCSS { + var componentCSS: String { ".inner { margin: 5px; }" } + var body: some View { Text("Inner") } + } + + struct OuterComponent: View, HasComponentCSS { + var componentCSS: String { ".outer { padding: 10px; }" } + var body: some View { InnerComponent() } + } + + let baseCSSURL = rootURL.appending(path: "base.css") + let cssOutputURL = rootURL.appending(path: "output.css") + try "".write(to: baseCSSURL, atomically: true, encoding: .utf8) + + let sitemap: Sitemap = ["index.html": OuterComponent()] + try renderSitemap( + sitemap, + to: rootURL, + cssConfiguration: (baseCSS: baseCSSURL, output: cssOutputURL) + ) + + let css = try String(contentsOf: cssOutputURL, encoding: .utf8) + #expect(css.contains(".outer { padding: 10px; }")) + #expect(css.contains(".inner { margin: 5px; }")) + } + + @Test func handlesMultiplePages() async throws { + struct PageA: View, HasComponentCSS { + var componentCSS: String { ".page-a { color: blue; }" } + var body: some View { Text("Page A") } + } + + struct PageB: View, HasComponentCSS { + var componentCSS: String { ".page-b { color: green; }" } + var body: some View { Text("Page B") } + } + + let baseCSSURL = rootURL.appending(path: "base.css") + let cssOutputURL = rootURL.appending(path: "output.css") + try "".write(to: baseCSSURL, atomically: true, encoding: .utf8) + + let sitemap: Sitemap = [ + "a.html": PageA(), + "b.html": PageB() + ] + + try renderSitemap( + sitemap, + to: rootURL, + cssConfiguration: (baseCSS: baseCSSURL, output: cssOutputURL) + ) + + let css = try String(contentsOf: cssOutputURL, encoding: .utf8) + #expect(css.contains(".page-a { color: blue; }")) + #expect(css.contains(".page-b { color: green; }")) + } + + @Test func doesNotCollectCSSWithoutConfiguration() async throws { + struct TestComponent: View, HasComponentCSS { + var componentCSS: 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 index 03b4ae69..c2a232e6 100644 --- a/Tests/SlipstreamTests/Rendering/RenderStylesTests.swift +++ b/Tests/SlipstreamTests/Rendering/RenderStylesTests.swift @@ -1,7 +1,7 @@ import Foundation import Testing -import Slipstream +@testable import Slipstream final class RenderStylesTests { From f873603a2c5609c8a66b94297cc461f2c5e50cad Mon Sep 17 00:00:00 2001 From: csjones Date: Mon, 10 Nov 2025 17:20:31 -0800 Subject: [PATCH 4/7] =?UTF-8?q?refactor:=20rename=20ComponentLayerContext?= =?UTF-8?q?=20=E2=86=92=20StyleContext,=20HasComponentCSS=20=E2=86=92=20St?= =?UTF-8?q?yleModifier=20per=20maintainer=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename ComponentLayerContext to StyleContext - Rename HasComponentCSS protocol to StyleModifier for idiomatic SwiftUI naming - Rename componentCSS property to style - Remove redundant internal keyword from renderStyles - Update all references across codebase and tests --- .../DataAndStorage/EnvironmentValues.swift | 14 +- .../Rendering/ComponentLayerContext.swift | 27 --- .../Slipstream/Rendering/RenderSitemap.swift | 6 +- .../Slipstream/Rendering/RenderStyles.swift | 14 +- .../Slipstream/Rendering/StyleContext.swift | 45 ++++ ...ComponentCSS.swift => StyleModifier.swift} | 18 +- ...mponentCollectionProofOfConceptTests.swift | 200 ++++++++++++++++++ .../Rendering/RenderSitemapCSSTests.swift | 28 +-- .../Rendering/RenderStylesTests.swift | 60 +++--- ...SSTests.swift => StyleModifierTests.swift} | 32 +-- 10 files changed, 331 insertions(+), 113 deletions(-) delete mode 100644 Sources/Slipstream/Rendering/ComponentLayerContext.swift create mode 100644 Sources/Slipstream/Rendering/StyleContext.swift rename Sources/Slipstream/Rendering/{HasComponentCSS.swift => StyleModifier.swift} (77%) create mode 100644 Tests/SlipstreamTests/Rendering/ComponentCollectionProofOfConceptTests.swift rename Tests/SlipstreamTests/Rendering/{HasComponentCSSTests.swift => StyleModifierTests.swift} (60%) diff --git a/Sources/Slipstream/Fundamentals/DataAndStorage/EnvironmentValues.swift b/Sources/Slipstream/Fundamentals/DataAndStorage/EnvironmentValues.swift index 10d573be..de3ceb9d 100644 --- a/Sources/Slipstream/Fundamentals/DataAndStorage/EnvironmentValues.swift +++ b/Sources/Slipstream/Fundamentals/DataAndStorage/EnvironmentValues.swift @@ -52,19 +52,19 @@ public struct EnvironmentValues: Sendable { private var storage: [ObjectIdentifier: Any & Sendable] = [:] } -// MARK: - Component Layer Context +// MARK: - Style Context -private struct ComponentLayerContextKey: EnvironmentKey { - static let defaultValue: ComponentLayerContext? = nil +private struct StyleContextKey: EnvironmentKey { + static let defaultValue: StyleContext? = nil } extension EnvironmentValues { /// The context for collecting CSS components during rendering. /// /// This is used internally by the rendering system to automatically - /// collect components that conform to `HasComponentCSS`. - var componentLayerContext: ComponentLayerContext? { - get { self[ComponentLayerContextKey.self] } - set { self[ComponentLayerContextKey.self] = newValue } + /// collect components that conform to `StyleModifier`. + var styleContext: StyleContext? { + get { self[StyleContextKey.self] } + set { self[StyleContextKey.self] = newValue } } } diff --git a/Sources/Slipstream/Rendering/ComponentLayerContext.swift b/Sources/Slipstream/Rendering/ComponentLayerContext.swift deleted file mode 100644 index f71a2b68..00000000 --- a/Sources/Slipstream/Rendering/ComponentLayerContext.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation - -/// Context for collecting CSS components during view rendering. -/// -/// This context is used internally by the rendering system to automatically -/// collect components that conform to `HasComponentCSS` while traversing the -/// view hierarchy. The collected components are then used to generate CSS -/// for the `@layer components` section in Tailwind CSS. -/// -/// This class is marked as `@unchecked Sendable` because it's only used -/// synchronously within a single rendering pass and is not shared across threads. -@available(iOS 17.0, macOS 14.0, *) -final class ComponentLayerContext: @unchecked Sendable { - private var components: [any HasComponentCSS] = [] - - /// Adds a component to the collection. - /// - /// - Parameter component: A component conforming to `HasComponentCSS`. - func add(_ component: any HasComponentCSS) { - components.append(component) - } - - /// All collected components. - var allComponents: [any HasComponentCSS] { - components - } -} diff --git a/Sources/Slipstream/Rendering/RenderSitemap.swift b/Sources/Slipstream/Rendering/RenderSitemap.swift index 9b3ae685..d525a9c9 100644 --- a/Sources/Slipstream/Rendering/RenderSitemap.swift +++ b/Sources/Slipstream/Rendering/RenderSitemap.swift @@ -18,11 +18,11 @@ public func renderSitemap( ) throws { // Create CSS collection context if CSS generation requested var environment = EnvironmentValues() - let cssContext: ComponentLayerContext? + let cssContext: StyleContext? if cssConfiguration != nil { - let context = ComponentLayerContext() - environment.componentLayerContext = context + let context = StyleContext() + environment.styleContext = context cssContext = context } else { cssContext = nil diff --git a/Sources/Slipstream/Rendering/RenderStyles.swift b/Sources/Slipstream/Rendering/RenderStyles.swift index 8ec027ec..76cc9c32 100644 --- a/Sources/Slipstream/Rendering/RenderStyles.swift +++ b/Sources/Slipstream/Rendering/RenderStyles.swift @@ -3,22 +3,22 @@ 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 `HasComponentCSS`, then writes the combined result to an output file. +/// 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 `HasComponentCSS`. +/// - 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, *) -internal func renderStyles( - from components: [any HasComponentCSS], +func renderStyles( + from components: [any StyleModifier], baseCSS: URL, to output: URL, useComponentLayer: Bool = true @@ -33,7 +33,7 @@ internal func renderStyles( // Deduplicate components by CSS content (first occurrence wins) var seenCSS = Set() let uniqueComponents = components.filter { component in - seenCSS.insert(component.componentCSS).inserted + seenCSS.insert(component.style).inserted } // Add component-specific styles @@ -47,13 +47,13 @@ internal func renderStyles( // Conditionally indent each line of component CSS if useComponentLayer { - let indentedCSS = component.componentCSS + let indentedCSS = component.style .split(separator: "\n", omittingEmptySubsequences: false) .map { $0.isEmpty ? "" : " \($0)" } .joined(separator: "\n") cssContent += indentedCSS } else { - cssContent += component.componentCSS + cssContent += component.style } cssContent += "\n\n" } diff --git a/Sources/Slipstream/Rendering/StyleContext.swift b/Sources/Slipstream/Rendering/StyleContext.swift new file mode 100644 index 00000000..f7c5c688 --- /dev/null +++ b/Sources/Slipstream/Rendering/StyleContext.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Context for collecting CSS components during view rendering. +/// +/// This context is used internally by the rendering system to automatically +/// collect components that conform to `StyleModifier` while traversing the +/// view hierarchy. The collected components are then used to generate CSS +/// for the `@layer components` section in Tailwind CSS. +/// +/// ## Sendable Conformance Rationale +/// +/// This class uses `@unchecked Sendable` because the Swift compiler cannot verify +/// the safety of the mutable state, but the usage pattern is provably safe: +/// +/// 1. **Single ownership**: Each `renderSitemap()` invocation creates exactly one instance +/// 2. **Sequential access**: The synchronous rendering loop (line 32-43 in RenderSitemap.swift) +/// processes views one at a time in a for-loop +/// 3. **No sharing**: The instance never escapes the function scope or crosses thread boundaries +/// 4. **Lifetime**: Created → Used → Released within a single call stack +/// +/// This is the recommended pattern from Swift Evolution proposals SE-0302 and SE-0306 +/// for isolated, single-threaded collections that need `Sendable` conformance for +/// API requirements (in this case, storage in `EnvironmentValues`) but are never +/// actually accessed concurrently. +/// +/// Alternative approaches considered and rejected: +/// - **Actor**: Would require async/await throughout the rendering pipeline (breaking change) +/// - **Locks**: Unnecessary synchronization overhead for guaranteed sequential access +/// - **MainActor**: Would couple rendering to main thread (overly restrictive) +@available(iOS 17.0, macOS 14.0, *) +final class StyleContext: @unchecked Sendable { + 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/HasComponentCSS.swift b/Sources/Slipstream/Rendering/StyleModifier.swift similarity index 77% rename from Sources/Slipstream/Rendering/HasComponentCSS.swift rename to Sources/Slipstream/Rendering/StyleModifier.swift index 7794bb42..e3bbdaf6 100644 --- a/Sources/Slipstream/Rendering/HasComponentCSS.swift +++ b/Sources/Slipstream/Rendering/StyleModifier.swift @@ -8,8 +8,8 @@ import SwiftSoup /// /// Example: /// ```swift -/// struct MyComponent: View, HasComponentCSS { -/// var componentCSS: String { +/// struct MyComponent: View, StyleModifier { +/// var style: String { /// """ /// .my-component { /// background-color: red; @@ -25,16 +25,16 @@ import SwiftSoup /// } /// ``` @available(iOS 17.0, macOS 14.0, *) -public protocol HasComponentCSS { +public protocol StyleModifier: Sendable { /// The CSS styles for this component instance. - var componentCSS: String { get } + 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 HasComponentCSS { +public extension StyleModifier { /// Default component name derived from the type name. /// /// This default implementation uses Swift's type reflection to generate @@ -47,15 +47,15 @@ public extension HasComponentCSS { // MARK: - Automatic Registration @available(iOS 17.0, macOS 14.0, *) -extension View where Self: HasComponentCSS { +extension View where Self: StyleModifier { /// Automatically registers this component's CSS when rendering. /// - /// Views conforming to `HasComponentCSS` are automatically registered with - /// the component layer context during rendering, allowing CSS to be collected + /// Views conforming to `StyleModifier` are automatically registered with + /// the style context during rendering, allowing CSS to be collected /// without manual component list management. public func render(_ container: Element, environment: EnvironmentValues) throws { // Register this component's CSS with the collection context - environment.componentLayerContext?.add(self) + environment.styleContext?.add(self) // Continue normal rendering try injectEnvironment(environment: environment).body.render(container, environment: environment) diff --git a/Tests/SlipstreamTests/Rendering/ComponentCollectionProofOfConceptTests.swift b/Tests/SlipstreamTests/Rendering/ComponentCollectionProofOfConceptTests.swift new file mode 100644 index 00000000..f95f4d1b --- /dev/null +++ b/Tests/SlipstreamTests/Rendering/ComponentCollectionProofOfConceptTests.swift @@ -0,0 +1,200 @@ +import Foundation +import Testing +import SwiftSoup + +@testable import Slipstream + +/// Tests documenting the automatic CSS collection architecture and its limitations. +/// +/// These tests demonstrate: +/// 1. Automatic registration works for views using `body` +/// 2. Custom `render()` implementations override the protocol extension (architectural limitation) +/// 3. Manual registration helper is available for the custom render() edge case +final class AutomaticCSSRegistrationTests { + + // MARK: - Test Helpers + + /// Simulates rendering and collects CSS components + private func renderAndCollectCSS(view: any View) throws -> [any StyleModifier] { + let context = StyleContext() + var environment = EnvironmentValues() + environment.styleContext = context + + let document = Document("/") + try view.render(document, environment: environment) + + return context.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 the architectural limitation: custom render() overrides protocol extension + struct ComponentWithCustomRender: View, StyleModifier { + var style: String { ".custom { color: blue; }" } + var componentName: String { "CustomRender" } + + // ❌ This custom render() OVERRIDES the protocol extension's render() + // Result: Component is NOT automatically registered + func render(_ container: Element, environment: EnvironmentValues) throws { + let element = try container.appendElement("custom") + try element.appendText("Custom rendered") + } + } + + /// Shows the workaround: manual registration in custom render() + struct ComponentWithManualRegistration: View, StyleModifier { + var style: String { ".manual { color: green; }" } + var componentName: String { "ManualRegistration" } + + func render(_ container: Element, environment: EnvironmentValues) throws { + // ✅ Manually register before custom render logic + environment.styleContext?.add(self) + + 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() throws { + let components = try 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() throws { + let component = ComponentWithRuntimeValue(id: "test-tabs", count: 5) + let components = try 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() throws { + let page = PageWithNestedComponents() + let components = try 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: Custom Render Limitation + + @Test("Custom render() overrides protocol extension - component NOT registered") + func customRenderOverridesAutomaticRegistration() throws { + let component = ComponentWithCustomRender() + let components = try renderAndCollectCSS(view: component) + + // ❌ ARCHITECTURAL LIMITATION: Custom render() overrides the protocol extension + // Result: Component is NOT automatically registered + #expect(components.isEmpty) + } + + @Test("Manual registration workaround for custom render()") + func manualRegistrationWorksForCustomRender() throws { + let component = ComponentWithManualRegistration() + let components = try renderAndCollectCSS(view: component) + + // ✅ Manual registration in custom render() works + #expect(components.count == 1) + #expect(components[0].componentName == "ManualRegistration") + } + + // MARK: - Tests: Deduplication + + @Test("CSS deduplication works (first occurrence wins)") + func cssDeduplication() 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 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") + } +} diff --git a/Tests/SlipstreamTests/Rendering/RenderSitemapCSSTests.swift b/Tests/SlipstreamTests/Rendering/RenderSitemapCSSTests.swift index 595bb498..5d90fc7a 100644 --- a/Tests/SlipstreamTests/Rendering/RenderSitemapCSSTests.swift +++ b/Tests/SlipstreamTests/Rendering/RenderSitemapCSSTests.swift @@ -24,8 +24,8 @@ final class RenderSitemapCSSTests { } @Test func collectsAndGeneratesCSS() async throws { - struct TestComponent: View, HasComponentCSS { - var componentCSS: String { ".test { color: red; }" } + struct TestComponent: View, StyleModifier { + var style: String { ".test { color: red; }" } var body: some View { Text("Test") } } @@ -48,8 +48,8 @@ final class RenderSitemapCSSTests { } @Test func deduplicatesSharedComponents() async throws { - struct Header: View, HasComponentCSS { - var componentCSS: String { ".header { padding: 10px; }" } + struct Header: View, StyleModifier { + var style: String { ".header { padding: 10px; }" } var body: some View { Text("Header") } } @@ -75,13 +75,13 @@ final class RenderSitemapCSSTests { } @Test func collectsFromNestedComponents() async throws { - struct InnerComponent: View, HasComponentCSS { - var componentCSS: String { ".inner { margin: 5px; }" } + struct InnerComponent: View, StyleModifier { + var style: String { ".inner { margin: 5px; }" } var body: some View { Text("Inner") } } - struct OuterComponent: View, HasComponentCSS { - var componentCSS: String { ".outer { padding: 10px; }" } + struct OuterComponent: View, StyleModifier { + var style: String { ".outer { padding: 10px; }" } var body: some View { InnerComponent() } } @@ -102,13 +102,13 @@ final class RenderSitemapCSSTests { } @Test func handlesMultiplePages() async throws { - struct PageA: View, HasComponentCSS { - var componentCSS: String { ".page-a { color: blue; }" } + struct PageA: View, StyleModifier { + var style: String { ".page-a { color: blue; }" } var body: some View { Text("Page A") } } - struct PageB: View, HasComponentCSS { - var componentCSS: String { ".page-b { color: green; }" } + struct PageB: View, StyleModifier { + var style: String { ".page-b { color: green; }" } var body: some View { Text("Page B") } } @@ -133,8 +133,8 @@ final class RenderSitemapCSSTests { } @Test func doesNotCollectCSSWithoutConfiguration() async throws { - struct TestComponent: View, HasComponentCSS { - var componentCSS: String { ".test { color: red; }" } + struct TestComponent: View, StyleModifier { + var style: String { ".test { color: red; }" } var body: some View { Text("Test") } } diff --git a/Tests/SlipstreamTests/Rendering/RenderStylesTests.swift b/Tests/SlipstreamTests/Rendering/RenderStylesTests.swift index c2a232e6..1f08a64d 100644 --- a/Tests/SlipstreamTests/Rendering/RenderStylesTests.swift +++ b/Tests/SlipstreamTests/Rendering/RenderStylesTests.swift @@ -36,8 +36,8 @@ final class RenderStylesTests { } @Test func combinesBaseCSSWithSingleComponent() async throws { - struct TestComponent: HasComponentCSS { - var componentCSS: String { + struct TestComponent: StyleModifier { + var style: String { """ .test-component { color: red; @@ -60,8 +60,8 @@ final class RenderStylesTests { } @Test func combinesBaseCSSWithMultipleComponents() async throws { - struct ComponentA: HasComponentCSS { - var componentCSS: String { + struct ComponentA: StyleModifier { + var style: String { """ .component-a { background: blue; @@ -71,8 +71,8 @@ final class RenderStylesTests { var componentName: String { "ComponentA" } } - struct ComponentB: HasComponentCSS { - var componentCSS: String { + struct ComponentB: StyleModifier { + var style: String { """ .component-b { font-size: 16px; @@ -117,8 +117,8 @@ final class RenderStylesTests { } @Test func usesDefaultComponentName() async throws { - struct MyCustomComponent: HasComponentCSS { - var componentCSS: String { + struct MyCustomComponent: StyleModifier { + var style: String { ".custom { color: green; }" } } @@ -135,8 +135,8 @@ final class RenderStylesTests { } @Test func handlesEmptyComponentCSS() async throws { - struct EmptyComponent: HasComponentCSS { - var componentCSS: String { "" } + struct EmptyComponent: StyleModifier { + var style: String { "" } var componentName: String { "EmptyComponent" } } @@ -153,8 +153,8 @@ final class RenderStylesTests { } @Test func handlesEmptyBaseCSS() async throws { - struct TestComponent: HasComponentCSS { - var componentCSS: String { ".test { color: red; }" } + struct TestComponent: StyleModifier { + var style: String { ".test { color: red; }" } var componentName: String { "TestComponent" } } @@ -171,8 +171,8 @@ final class RenderStylesTests { } @Test func withoutComponentLayerWrapsSingleComponent() async throws { - struct TestComponent: HasComponentCSS { - var componentCSS: String { + struct TestComponent: StyleModifier { + var style: String { """ .test-component { color: blue; @@ -195,8 +195,8 @@ final class RenderStylesTests { } @Test func withoutComponentLayerWrapsMultipleComponents() async throws { - struct ComponentA: HasComponentCSS { - var componentCSS: String { + struct ComponentA: StyleModifier { + var style: String { """ .component-a { background: green; @@ -206,8 +206,8 @@ final class RenderStylesTests { var componentName: String { "ComponentA" } } - struct ComponentB: HasComponentCSS { - var componentCSS: String { + struct ComponentB: StyleModifier { + var style: String { """ .component-b { font-size: 18px; @@ -243,8 +243,8 @@ final class RenderStylesTests { } @Test func deduplicatesComponentsWithIdenticalCSS() async throws { - struct Header: HasComponentCSS { - var componentCSS: String { ".header { background: blue; }" } + struct Header: StyleModifier { + var style: String { ".header { background: blue; }" } var componentName: String { "Header" } } @@ -263,13 +263,13 @@ final class RenderStylesTests { } @Test func deduplicatesPreservesFirstOccurrence() async throws { - struct ComponentA: HasComponentCSS { - var componentCSS: String { ".shared { color: red; }" } + struct ComponentA: StyleModifier { + var style: String { ".shared { color: red; }" } var componentName: String { "ComponentA" } } - struct ComponentB: HasComponentCSS { - var componentCSS: String { ".shared { color: red; }" } // Same CSS + struct ComponentB: StyleModifier { + var style: String { ".shared { color: red; }" } // Same CSS var componentName: String { "ComponentB" } } @@ -292,8 +292,8 @@ final class RenderStylesTests { } @Test func deduplicationWorksWithoutComponentLayer() async throws { - struct Header: HasComponentCSS { - var componentCSS: String { ".header { padding: 20px; }" } + struct Header: StyleModifier { + var style: String { ".header { padding: 20px; }" } var componentName: String { "Header" } } @@ -311,13 +311,13 @@ final class RenderStylesTests { } @Test func deduplicationKeepsUniqueComponents() async throws { - struct Header: HasComponentCSS { - var componentCSS: String { ".header { color: blue; }" } + struct Header: StyleModifier { + var style: String { ".header { color: blue; }" } var componentName: String { "Header" } } - struct Footer: HasComponentCSS { - var componentCSS: String { ".footer { color: gray; }" } + struct Footer: StyleModifier { + var style: String { ".footer { color: gray; }" } var componentName: String { "Footer" } } diff --git a/Tests/SlipstreamTests/Rendering/HasComponentCSSTests.swift b/Tests/SlipstreamTests/Rendering/StyleModifierTests.swift similarity index 60% rename from Tests/SlipstreamTests/Rendering/HasComponentCSSTests.swift rename to Tests/SlipstreamTests/Rendering/StyleModifierTests.swift index eefcb4ac..ca840570 100644 --- a/Tests/SlipstreamTests/Rendering/HasComponentCSSTests.swift +++ b/Tests/SlipstreamTests/Rendering/StyleModifierTests.swift @@ -2,11 +2,11 @@ import Testing import Slipstream -struct HasComponentCSSTests { +struct StyleModifierTests { @Test func defaultComponentNameUsesTypeName() async throws { - struct MyTestComponent: HasComponentCSS { - var componentCSS: String { ".test { color: blue; }" } + struct MyTestComponent: StyleModifier { + var style: String { ".test { color: blue; }" } } let component = MyTestComponent() @@ -14,8 +14,8 @@ struct HasComponentCSSTests { } @Test func defaultComponentNameWithGenericType() async throws { - struct GenericComponent: HasComponentCSS { - var componentCSS: String { ".generic { display: block; }" } + struct GenericComponent: StyleModifier { + var style: String { ".generic { display: block; }" } let value: T } @@ -24,8 +24,8 @@ struct HasComponentCSSTests { } @Test func customComponentNameOverridesDefault() async throws { - struct CustomNameComponent: HasComponentCSS { - var componentCSS: String { ".custom { margin: 0; }" } + struct CustomNameComponent: StyleModifier { + var style: String { ".custom { margin: 0; }" } var componentName: String { "MyCustomName" } } @@ -33,9 +33,9 @@ struct HasComponentCSSTests { #expect(component.componentName == "MyCustomName") } - @Test func componentCSSPropertyIsRequired() async throws { - struct MinimalComponent: HasComponentCSS { - var componentCSS: String { + @Test func stylePropertyIsRequired() async throws { + struct MinimalComponent: StyleModifier { + var style: String { """ .minimal { padding: 10px; @@ -45,13 +45,13 @@ struct HasComponentCSSTests { } let component = MinimalComponent() - #expect(component.componentCSS.contains(".minimal")) - #expect(component.componentCSS.contains("padding: 10px")) + #expect(component.style.contains(".minimal")) + #expect(component.style.contains("padding: 10px")) } @Test func multipleInstancesHaveSameComponentName() async throws { - struct ReusableComponent: HasComponentCSS { - var componentCSS: String { ".reusable { width: 100%; }" } + struct ReusableComponent: StyleModifier { + var style: String { ".reusable { width: 100%; }" } let id: Int } @@ -64,8 +64,8 @@ struct HasComponentCSSTests { @Test func nestedTypeComponentName() async throws { struct OuterComponent { - struct InnerComponent: HasComponentCSS { - var componentCSS: String { ".inner { color: red; }" } + struct InnerComponent: StyleModifier { + var style: String { ".inner { color: red; }" } } } From d603b82739e18da4f68488d0a6921bfaf9083c2f Mon Sep 17 00:00:00 2001 From: csjones Date: Tue, 11 Nov 2025 18:30:54 -0800 Subject: [PATCH 5/7] refactor: implement parallel style collection system for CSS generation - Separated style collection from rendering by adding dedicated `style()` method to View protocol - Changed StyleContext from @unchecked Sendable class to actor for proper concurrent access - Updated renderSitemap to traverse views for style collection before HTML rendering begins --- Sources/Slipstream/Fundamentals/AnyView.swift | 5 ++ .../Fundamentals/ClassModifier.swift | 4 + .../DataAndStorage/EnvironmentValues.swift | 2 +- .../Fundamentals/View+styleCollection.swift | 35 ++++++++ Sources/Slipstream/Fundamentals/View.swift | 10 +++ .../Fundamentals/ViewBuilder/ArrayView.swift | 6 ++ .../ViewBuilder/ConditionalView.swift | 9 +++ .../ViewBuilder/ForEachView.swift | 4 + .../Fundamentals/ViewBuilder/TupleView.swift | 10 +++ .../Slipstream/Rendering/RenderSitemap.swift | 76 ++++++++++++------ .../Slipstream/Rendering/StyleContext.swift | 32 ++------ .../Slipstream/Rendering/StyleModifier.swift | 18 ----- .../TailwindCSS/TailwindClassModifier.swift | 4 + .../Slipstream/W3C/Elements/W3CElement.swift | 7 ++ .../Rendering/RenderSitemapCSSTests.swift | 32 ++++---- ...Tests.swift => StyleCollectionTests.swift} | 80 ++++++++++--------- 16 files changed, 208 insertions(+), 126 deletions(-) create mode 100644 Sources/Slipstream/Fundamentals/View+styleCollection.swift rename Tests/SlipstreamTests/Rendering/{ComponentCollectionProofOfConceptTests.swift => StyleCollectionTests.swift} (68%) 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/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 de3ceb9d..9336ed85 100644 --- a/Sources/Slipstream/Fundamentals/DataAndStorage/EnvironmentValues.swift +++ b/Sources/Slipstream/Fundamentals/DataAndStorage/EnvironmentValues.swift @@ -59,7 +59,7 @@ private struct StyleContextKey: EnvironmentKey { } extension EnvironmentValues { - /// The context for collecting CSS components during rendering. + /// 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`. 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 d525a9c9..7a6dccaf 100644 --- a/Sources/Slipstream/Rendering/RenderSitemap.swift +++ b/Sources/Slipstream/Rendering/RenderSitemap.swift @@ -5,30 +5,66 @@ import SwiftSoup /// /// - Parameter sitemap: A mapping of relative paths to Slipstream views. /// - Parameter folder: The root folder of the sitemap. -/// - Parameter cssConfiguration: Optional tuple specifying base CSS file and output location for generated component CSS. -/// When provided, components conforming to `HasComponentCSS` are automatically collected during rendering and their -/// CSS is combined with the base CSS file. The result is written to the specified output location. +/// - 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, encoding: String.Encoding = .utf8) throws { + let environment = EnvironmentValues() + + // 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 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, - cssConfiguration: (baseCSS: URL, output: URL)? = nil, + baseCSS: URL, + stylesheet: String = "styles.css", encoding: String.Encoding = .utf8 -) throws { - // Create CSS collection context if CSS generation requested +) async throws { var environment = EnvironmentValues() - let cssContext: StyleContext? - - if cssConfiguration != nil { - let context = StyleContext() - environment.styleContext = context - cssContext = context - } else { - cssContext = nil + 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) } - // Render HTML pages (collecting CSS if context present) + 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) @@ -41,16 +77,6 @@ public func renderSitemap( } try output.write(to: fileURL, atomically: true, encoding: encoding) } - - // Generate CSS file if requested - if let cssConfiguration = cssConfiguration, - let cssContext = cssContext { - try renderStyles( - from: cssContext.allComponents, - baseCSS: cssConfiguration.baseCSS, - to: cssConfiguration.output - ) - } } /// Renders a sitemap in parallel and returns the rendered pages. diff --git a/Sources/Slipstream/Rendering/StyleContext.swift b/Sources/Slipstream/Rendering/StyleContext.swift index f7c5c688..a036b835 100644 --- a/Sources/Slipstream/Rendering/StyleContext.swift +++ b/Sources/Slipstream/Rendering/StyleContext.swift @@ -1,34 +1,12 @@ import Foundation -/// Context for collecting CSS components during view rendering. +/// Context for collecting CSS components during view traversal. /// -/// This context is used internally by the rendering system to automatically -/// collect components that conform to `StyleModifier` while traversing the -/// view hierarchy. The collected components are then used to generate CSS -/// for the `@layer components` section in Tailwind CSS. -/// -/// ## Sendable Conformance Rationale -/// -/// This class uses `@unchecked Sendable` because the Swift compiler cannot verify -/// the safety of the mutable state, but the usage pattern is provably safe: -/// -/// 1. **Single ownership**: Each `renderSitemap()` invocation creates exactly one instance -/// 2. **Sequential access**: The synchronous rendering loop (line 32-43 in RenderSitemap.swift) -/// processes views one at a time in a for-loop -/// 3. **No sharing**: The instance never escapes the function scope or crosses thread boundaries -/// 4. **Lifetime**: Created → Used → Released within a single call stack -/// -/// This is the recommended pattern from Swift Evolution proposals SE-0302 and SE-0306 -/// for isolated, single-threaded collections that need `Sendable` conformance for -/// API requirements (in this case, storage in `EnvironmentValues`) but are never -/// actually accessed concurrently. -/// -/// Alternative approaches considered and rejected: -/// - **Actor**: Would require async/await throughout the rendering pipeline (breaking change) -/// - **Locks**: Unnecessary synchronization overhead for guaranteed sequential access -/// - **MainActor**: Would couple rendering to main thread (overly restrictive) +/// 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, *) -final class StyleContext: @unchecked Sendable { +actor StyleContext { private var components: [any StyleModifier] = [] /// Adds a component to the collection. diff --git a/Sources/Slipstream/Rendering/StyleModifier.swift b/Sources/Slipstream/Rendering/StyleModifier.swift index e3bbdaf6..fa9a6c63 100644 --- a/Sources/Slipstream/Rendering/StyleModifier.swift +++ b/Sources/Slipstream/Rendering/StyleModifier.swift @@ -43,21 +43,3 @@ public extension StyleModifier { String(describing: type(of: self)) } } - -// MARK: - Automatic Registration - -@available(iOS 17.0, macOS 14.0, *) -extension View where Self: StyleModifier { - /// Automatically registers this component's CSS when rendering. - /// - /// Views conforming to `StyleModifier` are automatically registered with - /// the style context during rendering, allowing CSS to be collected - /// without manual component list management. - public func render(_ container: Element, environment: EnvironmentValues) throws { - // Register this component's CSS with the collection context - environment.styleContext?.add(self) - - // Continue normal rendering - try injectEnvironment(environment: environment).body.render(container, environment: environment) - } -} 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 index 5d90fc7a..79083030 100644 --- a/Tests/SlipstreamTests/Rendering/RenderSitemapCSSTests.swift +++ b/Tests/SlipstreamTests/Rendering/RenderSitemapCSSTests.swift @@ -30,18 +30,18 @@ final class RenderSitemapCSSTests { } let baseCSSURL = rootURL.appending(path: "base.css") - let cssOutputURL = rootURL.appending(path: "output.css") try "/* Base */".write(to: baseCSSURL, atomically: true, encoding: .utf8) let sitemap: Sitemap = ["index.html": TestComponent()] - try renderSitemap( + try await renderSitemap( sitemap, to: rootURL, - cssConfiguration: (baseCSS: baseCSSURL, output: cssOutputURL) + baseCSS: baseCSSURL, + stylesheet: "output.css" ) - let css = try String(contentsOf: cssOutputURL, encoding: .utf8) + 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")) @@ -54,7 +54,6 @@ final class RenderSitemapCSSTests { } let baseCSSURL = rootURL.appending(path: "base.css") - let cssOutputURL = rootURL.appending(path: "output.css") try "".write(to: baseCSSURL, atomically: true, encoding: .utf8) let sitemap: Sitemap = [ @@ -63,13 +62,14 @@ final class RenderSitemapCSSTests { "page3.html": Header() ] - try renderSitemap( + try await renderSitemap( sitemap, to: rootURL, - cssConfiguration: (baseCSS: baseCSSURL, output: cssOutputURL) + baseCSS: baseCSSURL, + stylesheet: "output.css" ) - let css = try String(contentsOf: cssOutputURL, encoding: .utf8) + 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 } @@ -86,17 +86,17 @@ final class RenderSitemapCSSTests { } let baseCSSURL = rootURL.appending(path: "base.css") - let cssOutputURL = rootURL.appending(path: "output.css") try "".write(to: baseCSSURL, atomically: true, encoding: .utf8) let sitemap: Sitemap = ["index.html": OuterComponent()] - try renderSitemap( + try await renderSitemap( sitemap, to: rootURL, - cssConfiguration: (baseCSS: baseCSSURL, output: cssOutputURL) + baseCSS: baseCSSURL, + stylesheet: "output.css" ) - let css = try String(contentsOf: cssOutputURL, encoding: .utf8) + let css = try String(contentsOf: rootURL.appending(path: "output.css"), encoding: .utf8) #expect(css.contains(".outer { padding: 10px; }")) #expect(css.contains(".inner { margin: 5px; }")) } @@ -113,7 +113,6 @@ final class RenderSitemapCSSTests { } let baseCSSURL = rootURL.appending(path: "base.css") - let cssOutputURL = rootURL.appending(path: "output.css") try "".write(to: baseCSSURL, atomically: true, encoding: .utf8) let sitemap: Sitemap = [ @@ -121,13 +120,14 @@ final class RenderSitemapCSSTests { "b.html": PageB() ] - try renderSitemap( + try await renderSitemap( sitemap, to: rootURL, - cssConfiguration: (baseCSS: baseCSSURL, output: cssOutputURL) + baseCSS: baseCSSURL, + stylesheet: "output.css" ) - let css = try String(contentsOf: cssOutputURL, encoding: .utf8) + 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; }")) } diff --git a/Tests/SlipstreamTests/Rendering/ComponentCollectionProofOfConceptTests.swift b/Tests/SlipstreamTests/Rendering/StyleCollectionTests.swift similarity index 68% rename from Tests/SlipstreamTests/Rendering/ComponentCollectionProofOfConceptTests.swift rename to Tests/SlipstreamTests/Rendering/StyleCollectionTests.swift index f95f4d1b..85041035 100644 --- a/Tests/SlipstreamTests/Rendering/ComponentCollectionProofOfConceptTests.swift +++ b/Tests/SlipstreamTests/Rendering/StyleCollectionTests.swift @@ -4,26 +4,26 @@ import SwiftSoup @testable import Slipstream -/// Tests documenting the automatic CSS collection architecture and its limitations. +/// Tests for the automatic CSS style collection system. /// -/// These tests demonstrate: -/// 1. Automatic registration works for views using `body` -/// 2. Custom `render()` implementations override the protocol extension (architectural limitation) -/// 3. Manual registration helper is available for the custom render() edge case -final class AutomaticCSSRegistrationTests { +/// 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 - /// Simulates rendering and collects CSS components - private func renderAndCollectCSS(view: any View) throws -> [any StyleModifier] { - let context = StyleContext() + /// 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 = context + environment.styleContext = styleContext - let document = Document("/") - try view.render(document, environment: environment) + try await view.style(environment: environment) - return context.allComponents + return await styleContext.allComponents } // MARK: - Test Components @@ -54,28 +54,29 @@ final class AutomaticCSSRegistrationTests { } } - /// Demonstrates the architectural limitation: custom render() overrides protocol extension + /// 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" } - // ❌ This custom render() OVERRIDES the protocol extension's render() - // Result: Component is NOT automatically registered + // ✅ 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 the workaround: manual registration in custom render() + /// 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 { - // ✅ Manually register before custom render logic - environment.styleContext?.add(self) - let element = try container.appendElement("manual") try element.appendText("Manually registered") } @@ -94,8 +95,8 @@ final class AutomaticCSSRegistrationTests { // MARK: - Tests: Automatic Registration (Works) @Test("Automatic registration works for simple component with body") - func automaticRegistrationForBodyComponent() throws { - let components = try renderAndCollectCSS(view: SimpleComponent()) + func automaticRegistrationForBodyComponent() async throws { + let components = try await renderAndCollectCSS(view: SimpleComponent()) #expect(components.count == 1) #expect(components[0].componentName == "SimpleComponent") @@ -103,9 +104,9 @@ final class AutomaticCSSRegistrationTests { } @Test("Automatic registration captures runtime values") - func automaticRegistrationWithRuntimeValues() throws { + func automaticRegistrationWithRuntimeValues() async throws { let component = ComponentWithRuntimeValue(id: "test-tabs", count: 5) - let components = try renderAndCollectCSS(view: component) + let components = try await renderAndCollectCSS(view: component) #expect(components.count == 1) #expect(components[0].componentName == "Component[test-tabs]") @@ -114,9 +115,9 @@ final class AutomaticCSSRegistrationTests { } @Test("Automatic registration works for nested components") - func automaticRegistrationForNestedComponents() throws { + func automaticRegistrationForNestedComponents() async throws { let page = PageWithNestedComponents() - let components = try renderAndCollectCSS(view: page) + let components = try await renderAndCollectCSS(view: page) // Should automatically find all 3 nested components #expect(components.count == 3) @@ -134,24 +135,25 @@ final class AutomaticCSSRegistrationTests { #expect(footerComponent?.style.contains("with 2 items") == true) } - // MARK: - Tests: Custom Render Limitation + // MARK: - Tests: style() Works With Custom Render - @Test("Custom render() overrides protocol extension - component NOT registered") - func customRenderOverridesAutomaticRegistration() throws { + @Test("style() method collects CSS even with custom render()") + func styleMethodWorksWithCustomRender() async throws { let component = ComponentWithCustomRender() - let components = try renderAndCollectCSS(view: component) + let components = try await renderAndCollectCSS(view: component) - // ❌ ARCHITECTURAL LIMITATION: Custom render() overrides the protocol extension - // Result: Component is NOT automatically registered - #expect(components.isEmpty) + // ✅ style() method is independent of render() + // Result: Component IS collected even with custom render() + #expect(components.count == 1) + #expect(components[0].componentName == "CustomRender") } - @Test("Manual registration workaround for custom render()") - func manualRegistrationWorksForCustomRender() throws { + @Test("style() method works for all components with custom render()") + func styleMethodWorksForAllCustomRenderComponents() async throws { let component = ComponentWithManualRegistration() - let components = try renderAndCollectCSS(view: component) + let components = try await renderAndCollectCSS(view: component) - // ✅ Manual registration in custom render() works + // ✅ style() method collects CSS regardless of custom render() #expect(components.count == 1) #expect(components[0].componentName == "ManualRegistration") } @@ -159,7 +161,7 @@ final class AutomaticCSSRegistrationTests { // MARK: - Tests: Deduplication @Test("CSS deduplication works (first occurrence wins)") - func cssDeduplication() throws { + func cssDeduplication() async throws { struct DupeA: View, StyleModifier { var style: String { ".dupe { margin: 10px; }" } var componentName: String { "DupeA" } @@ -182,7 +184,7 @@ final class AutomaticCSSRegistrationTests { } let page = PageWithDupes() - let components = try renderAndCollectCSS(view: page) + let components = try await renderAndCollectCSS(view: page) // Both components collected #expect(components.count == 2) From 48d428abedce688d99f015d931fb4ff1153fcdce Mon Sep 17 00:00:00 2001 From: csjones Date: Tue, 11 Nov 2025 19:02:33 -0800 Subject: [PATCH 6/7] Restore original renderSitemap --- .../Slipstream/Rendering/RenderSitemap.swift | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/Sources/Slipstream/Rendering/RenderSitemap.swift b/Sources/Slipstream/Rendering/RenderSitemap.swift index 7a6dccaf..af0d455e 100644 --- a/Sources/Slipstream/Rendering/RenderSitemap.swift +++ b/Sources/Slipstream/Rendering/RenderSitemap.swift @@ -8,21 +8,15 @@ import SwiftSoup /// - 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, encoding: String.Encoding = .utf8) throws { - let environment = EnvironmentValues() - - // 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) + for (path, view) in sitemap.sorted(by: { $0.key < $1.key }) { + let output = try "\n" + renderHTML(view) + 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 the given sitemap to a folder with CSS component collection. From 4c5ca420f20b43ac5615cdc0761bd958457e1717 Mon Sep 17 00:00:00 2001 From: csjones Date: Fri, 2 Jan 2026 03:37:52 -0800 Subject: [PATCH 7/7] fix: add style() traversal through AttributeModifierView to fix CSS collection - Implement style() method in AttributeModifierView to propagate style collection through attribute modifiers - Add regression tests verifying CSS collection works through .language(), .id(), and other attribute modifiers - Add integration tests for deeply nested wrappers and ForEach with attributes --- .../Fundamentals/AttributeModifier.swift | 4 + .../Rendering/StyleCollectionTests.swift | 77 +++++++++++++++++++ 2 files changed, 81 insertions(+) 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/Tests/SlipstreamTests/Rendering/StyleCollectionTests.swift b/Tests/SlipstreamTests/Rendering/StyleCollectionTests.swift index 85041035..0e7c287f 100644 --- a/Tests/SlipstreamTests/Rendering/StyleCollectionTests.swift +++ b/Tests/SlipstreamTests/Rendering/StyleCollectionTests.swift @@ -199,4 +199,81 @@ final class StyleCollectionTests { #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]")) + } }