Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Sources/Slipstream/Fundamentals/AnyView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
4 changes: 4 additions & 0 deletions Sources/Slipstream/Fundamentals/AttributeModifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions Sources/Slipstream/Fundamentals/ClassModifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ private struct ClassModifierView<Content: View>: View {
try container.appendChild(child)
}
}

public func style(environment: EnvironmentValues) async throws {
try await self.content().style(environment: environment)
}

private let classNames: Set<String>
private let content: @Sendable () -> Content
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,20 @@ public struct EnvironmentValues: Sendable {

private var storage: [ObjectIdentifier: Any & Sendable] = [:]
}

// MARK: - Style Context

private struct StyleContextKey: EnvironmentKey {
static let defaultValue: StyleContext? = nil
}

extension EnvironmentValues {
/// The context for collecting CSS components during style traversal.
///
/// This is used internally by the rendering system to automatically
/// collect components that conform to `StyleModifier`.
var styleContext: StyleContext? {
get { self[StyleContextKey.self] }
set { self[StyleContextKey.self] = newValue }
}
}
35 changes: 35 additions & 0 deletions Sources/Slipstream/Fundamentals/View+styleCollection.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
10 changes: 10 additions & 0 deletions Sources/Slipstream/Fundamentals/View.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions Sources/Slipstream/Fundamentals/ViewBuilder/ArrayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,13 @@ public struct ConditionalView<T: View, F: View>: 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)
}
}
}
4 changes: 4 additions & 0 deletions Sources/Slipstream/Fundamentals/ViewBuilder/ForEachView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ public struct ForEach<Data, ID, Content>: 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`.
Expand Down
10 changes: 10 additions & 0 deletions Sources/Slipstream/Fundamentals/ViewBuilder/TupleView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
55 changes: 55 additions & 0 deletions Sources/Slipstream/Rendering/RenderSitemap.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import SwiftSoup

/// Renders the given sitemap to a folder.
///
Expand All @@ -18,6 +19,60 @@ public func renderSitemap(_ sitemap: Sitemap, to folder: URL, encoding: String.E
}
}

/// Renders the given sitemap to a folder with CSS component collection.
///
/// This async variant automatically collects CSS from components conforming to `StyleModifier`
/// by traversing the view hierarchy. The collected CSS is combined with the base CSS file
/// and written to the specified stylesheet location within the site folder.
///
/// - Parameter sitemap: A mapping of relative paths to Slipstream views.
/// - Parameter folder: The root folder of the sitemap where HTML and CSS files are generated.
/// - Parameter baseCSS: URL to the base CSS file that will be combined with component styles.
/// - Parameter stylesheet: Path relative to `folder` where the generated CSS will be written. Defaults to "styles.css".
/// - Parameter encoding: The encoding to use when saving files to disk.
/// - Throws: A SwiftSoup `Exception.Error` may be thrown if a failure occurs while rendering the view.
public func renderSitemap(
_ sitemap: Sitemap,
to folder: URL,
baseCSS: URL,
stylesheet: String = "styles.css",
encoding: String.Encoding = .utf8
) async throws {
var environment = EnvironmentValues()
let styleContext = StyleContext()
environment.styleContext = styleContext

// Traverse all views to collect CSS components
// This must complete before CSS generation begins
for (_, view) in sitemap {
try await view.style(environment: environment)
}

let allComponents = await styleContext.allComponents

// Generate CSS file to folder + stylesheet path
let stylesheetURL = folder.appending(path: stylesheet)
try renderStyles(
from: allComponents,
baseCSS: baseCSS,
to: stylesheetURL
)

// Render HTML pages
for (path, view) in sitemap.sorted(by: { $0.key < $1.key }) {
let document = Document("/")
try view.render(document, environment: environment)
let output = try "<!DOCTYPE html>\n" + document.html()

let fileURL = folder.appending(path: path)
let folderURL = fileURL.deletingLastPathComponent()
if !FileManager.default.fileExists(atPath: folderURL.path(percentEncoded: false)) {
try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true)
}
try output.write(to: fileURL, atomically: true, encoding: encoding)
}
}

/// Renders a sitemap in parallel and returns the rendered pages.
///
/// - Parameter sitemap: A mapping of relative paths to Slipstream views.
Expand Down
71 changes: 71 additions & 0 deletions Sources/Slipstream/Rendering/RenderStyles.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import Foundation

/// Renders styles by combining base CSS with component-specific styles.
///
/// This function reads a base CSS file and appends CSS from all component instances
/// conforming to `StyleModifier`, then writes the combined result to an output file.
/// Component styles are automatically deduplicated by CSS content and wrapped in `@layer components`
/// to ensure proper Tailwind CSS cascade order (base → components → utilities).
///
/// Components with identical CSS content are automatically deduplicated (first occurrence wins).
/// This is useful when the same component (e.g., a site header) is used across multiple pages.
///
/// - Parameter components: Array of component instances that conform to `StyleModifier`.
/// - Parameter baseCSS: URL to the base CSS file to read.
/// - Parameter output: URL where the combined CSS should be written.
/// - Parameter useComponentLayer: Whether to wrap component styles in `@layer components` for Tailwind CSS v3
/// cascade ordering. Defaults to `true`. Set to `false` for Tailwind CSS v4's automatic ordering.
/// - Throws: `CocoaError` if the base CSS file cannot be read, or if the output file or directory cannot be created or written.
@available(iOS 17.0, macOS 14.0, *)
func renderStyles(
from components: [any StyleModifier],
baseCSS: URL,
to output: URL,
useComponentLayer: Bool = true
) throws {
var cssContent = ""

// Read base CSS file
let baseContent = try String(contentsOf: baseCSS, encoding: .utf8)
cssContent += baseContent
cssContent += "\n\n"

// Deduplicate components by CSS content (first occurrence wins)
var seenCSS = Set<String>()
let uniqueComponents = components.filter { component in
seenCSS.insert(component.style).inserted
}

// Add component-specific styles
if useComponentLayer {
cssContent += "@layer components {\n"
}

for component in uniqueComponents {
let indent = useComponentLayer ? " " : ""
cssContent += "\(indent)/* \(component.componentName) */\n"

// Conditionally indent each line of component CSS
if useComponentLayer {
let indentedCSS = component.style
.split(separator: "\n", omittingEmptySubsequences: false)
.map { $0.isEmpty ? "" : " \($0)" }
.joined(separator: "\n")
cssContent += indentedCSS
} else {
cssContent += component.style
}
cssContent += "\n\n"
}

if useComponentLayer {
cssContent += "}\n"
}

// Ensure output directory exists
let outputDir = output.deletingLastPathComponent()
try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)

// Write combined CSS to output file
try cssContent.write(to: output, atomically: true, encoding: .utf8)
}
23 changes: 23 additions & 0 deletions Sources/Slipstream/Rendering/StyleContext.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Foundation

/// Context for collecting CSS components during view traversal.
///
/// This context is used internally by Slipstream to automatically collect
/// components that conform to `StyleModifier` while traversing the view
/// hierarchy via the `style()` method.
@available(iOS 17.0, macOS 14.0, *)
actor StyleContext {
private var components: [any StyleModifier] = []

/// Adds a component to the collection.
///
/// - Parameter component: A component conforming to `StyleModifier`.
func add(_ component: any StyleModifier) {
components.append(component)
}

/// All collected components.
var allComponents: [any StyleModifier] {
components
}
}
45 changes: 45 additions & 0 deletions Sources/Slipstream/Rendering/StyleModifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import Foundation
import SwiftSoup

/// A protocol for components that provide CSS styles.
///
/// Components conforming to this protocol can expose CSS styles that should be
/// included in the final stylesheet during the build process.
///
/// Example:
/// ```swift
/// struct MyComponent: View, StyleModifier {
/// var style: String {
/// """
/// .my-component {
/// background-color: red;
/// }
/// """
/// }
///
/// var componentName: String { "MyComponent" }
///
/// var body: some View {
/// // Component implementation
/// }
/// }
/// ```
@available(iOS 17.0, macOS 14.0, *)
public protocol StyleModifier: Sendable {
/// The CSS styles for this component instance.
var style: String { get }

/// A descriptive name for this component (used in CSS comments).
var componentName: String { get }
}

@available(iOS 17.0, macOS 14.0, *)
public extension StyleModifier {
/// Default component name derived from the type name.
///
/// This default implementation uses Swift's type reflection to generate
/// a component name from the conforming type's name.
var componentName: String {
String(describing: type(of: self))
}
}
4 changes: 4 additions & 0 deletions Sources/Slipstream/TailwindCSS/TailwindClassModifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ private struct TailwindClassModifierView<Content: View>: View {
try container.appendChild(child)
}
}

public func style(environment: EnvironmentValues) async throws {
try await self.content().style(environment: environment)
}

private let classNames: Set<String>
private let condition: Condition?
Expand Down
7 changes: 7 additions & 0 deletions Sources/Slipstream/W3C/Elements/W3CElement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading