diff --git a/Makefile b/Makefile index 4a45be2..d3d4294 100644 --- a/Makefile +++ b/Makefile @@ -42,7 +42,7 @@ test: swift test --parallel docker-test: - docker build -t tests . -f ./docker/Dockerfile.testing && docker run --rm tests + docker build -t swift-web-standards-tests . -f ./docker/tests/Dockerfile && docker run --rm swift-web-standards-tests diff --git a/Package.swift b/Package.swift index 6e7110a..ca430c1 100644 --- a/Package.swift +++ b/Package.swift @@ -2,8 +2,7 @@ import PackageDescription // NOTE: https://github.com/swift-server/swift-http-server/blob/main/Package.swift -var defaultSwiftSettings: [SwiftSetting] = -[ +var defaultSwiftSettings: [SwiftSetting] = [ // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0441-formalize-language-mode-terminology.md .swiftLanguageMode(.v6), // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0444-member-import-visibility.md @@ -11,7 +10,7 @@ var defaultSwiftSettings: [SwiftSetting] = // https://forums.swift.org/t/experimental-support-for-lifetime-dependencies-in-swift-6-2-and-beyond/78638 .enableExperimentalFeature("Lifetimes"), // https://github.com/swiftlang/swift/pull/65218 - .enableExperimentalFeature("AvailabilityMacro=swift-web-standards 1.0:macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0"), + .enableExperimentalFeature("AvailabilityMacro=swiftWebStandards 1.0:macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0"), ] #if compiler(>=6.2) @@ -37,7 +36,10 @@ let package = Package( ], dependencies: [ // [docc-plugin-placeholder] - .package(url: "https://github.com/apple/swift-collections", .upToNextMinor(from: "1.3.0")), + .package( + url: "https://github.com/apple/swift-collections", + from: "1.3.0" + ), ], targets: [ .target( diff --git a/README.md b/README.md index 4a80033..15ac510 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,11 @@ An awesome Swift library that closely follows the [W3C web standards](https://www.w3.org/standards/). -[![Release: 1.0.0-beta.2](https://img.shields.io/badge/Release-1.0.0--beta.2-F05138)]( https://github.com/binarybirds/swift-web-standards/releases/tag/1.0.0-beta.2) +[ + ![Release: 1.0.0-beta.1](https://img.shields.io/badge/Release-1.0.0--beta.1-F05138) +]( + https://github.com/binarybirds/swift-web-standards/releases/tag/1.0.0-beta.1 +) ## Features @@ -46,7 +50,7 @@ The Swift Web Standards package is distributed through **Swift Package Manager** Add this package to your `Package.swift` dependencies: ```swift -.package(url: "https://github.com/binarybirds/swift-web-standards", from: "1.0.0-beta.2"), +.package(url: "https://github.com/binarybirds/swift-web-standards", from: "1.0.0-beta.1"), ``` Then include the required product as a dependency for your target: @@ -325,7 +329,11 @@ let css = Stylesheet { print(StylesheetRenderer(minify: false, indent: 4).render(css)) ``` -[![DocC API documentation](https://img.shields.io/badge/DocC-API_documentation-F05138)](https://binarybirds.github.io/swift-web-standards) +[ + ![DocC API documentation](https://img.shields.io/badge/DocC-API_documentation-F05138) +]( + https://binarybirds.github.io/swift-web-standards +) API documentation is available at the following link. diff --git a/Sources/CSS/Properties/BackdropFilter.swift b/Sources/CSS/Properties/BackdropFilter.swift index 9730b9e..7b6e72b 100644 --- a/Sources/CSS/Properties/BackdropFilter.swift +++ b/Sources/CSS/Properties/BackdropFilter.swift @@ -7,31 +7,13 @@ /// CSS `backdrop-filter` property. /// Provides typed values for this declaration. public struct BackdropFilter: Property { - /// Supported length value for `backdrop-filter: blur(...)`. - public struct BlurLength: UnitRepresentable, Sendable { - public let rawValue: String - - /// Creates a blur length from a CSS `Unit`. - /// Returns `nil` for unsupported units (for example `%`). - public init?( - _ unit: Unit - ) where T: Numeric & Sendable { - switch unit.type { - case .cm, .mm, .in, .px, .pt, .pc, .em, .ex, .ch, .rem, .vw, .vh, - .vmin, .vmax: - self.rawValue = unit.rawValue - case .percent: - return nil - } - } - } /// Value options for the `backdrop-filter` property. public enum Value: Sendable { /// Default value. Specifies no effects. case none /// Applies a blur effect to the backdrop. - case blur(BlurLength) + case blur(UnitRepresentable) /// Sets this property to its default value. case initial /// Inherits this property from its parent element. @@ -65,17 +47,6 @@ public struct BackdropFilter: Property { self.isImportant = false } - /// Convenience initializer for `backdrop-filter: blur(...)` using `Unit`. - /// Returns `nil` when the supplied unit is unsupported by blur(). - public init?( - blur unit: Unit - ) where T: Numeric & Sendable { - guard let blurLength = BlurLength(unit) else { - return nil - } - self.init(.blur(blurLength)) - } - // TODO: add support for the remaining backdrop-filter functions: // - brightness() // - contrast() diff --git a/Sources/CSS/Properties/Border.swift b/Sources/CSS/Properties/Border.swift index 572ddc5..ddc50fa 100644 --- a/Sources/CSS/Properties/Border.swift +++ b/Sources/CSS/Properties/Border.swift @@ -11,7 +11,8 @@ public struct Border: Property { /// Value options for the `border` property. public enum Value: Sendable { - case values(BorderWidth.Value, BorderStyle.Value, CSSColor) + + case values(BorderWidth.Value, BorderStyle.Value?, CSSColor?) /// Sets this property to its default value. case initial /// Inherits this property from its parent element. @@ -20,8 +21,9 @@ public struct Border: Property { var rawValue: String { switch self { case .values(let width, let style, let color): - return width.rawValue + " " + style.rawValue + " " - + color.rawValue + return [width.rawValue, style?.rawValue, color?.rawValue] + .compactMap { $0 } + .joined(separator: " ") case .initial: return "initial" case .inherit: @@ -43,4 +45,21 @@ public struct Border: Property { self.value = value.rawValue self.isImportant = false } + + public init( + _ width: BorderWidth.Value, + _ style: BorderStyle.Value? = nil, + _ color: CSSColor? = nil + ) { + self.init(.values(width, style, color)) + } + + public init( + _ width: UnitRepresentable, + _ style: BorderStyle.Value? = nil, + _ color: CSSColor? = nil + ) { + self.init(.length(width), style, color) + } + } diff --git a/Sources/CSS/Properties/BorderBottom.swift b/Sources/CSS/Properties/BorderBottom.swift index c1bf103..6d1df79 100644 --- a/Sources/CSS/Properties/BorderBottom.swift +++ b/Sources/CSS/Properties/BorderBottom.swift @@ -19,4 +19,20 @@ public struct BorderBottom: Property { self.value = value.rawValue self.isImportant = false } + + public init( + _ width: BorderWidth.Value, + _ style: BorderStyle.Value? = nil, + _ color: CSSColor? = nil + ) { + self.init(.values(width, style, color)) + } + + public init( + _ width: UnitRepresentable, + _ style: BorderStyle.Value? = nil, + _ color: CSSColor? = nil + ) { + self.init(.length(width), style, color) + } } diff --git a/Sources/CSS/Properties/BorderLeft.swift b/Sources/CSS/Properties/BorderLeft.swift index ca86f86..2148cce 100644 --- a/Sources/CSS/Properties/BorderLeft.swift +++ b/Sources/CSS/Properties/BorderLeft.swift @@ -19,4 +19,20 @@ public struct BorderLeft: Property { self.value = value.rawValue self.isImportant = false } + + public init( + _ width: BorderWidth.Value, + _ style: BorderStyle.Value? = nil, + _ color: CSSColor? = nil + ) { + self.init(.values(width, style, color)) + } + + public init( + _ width: UnitRepresentable, + _ style: BorderStyle.Value? = nil, + _ color: CSSColor? = nil + ) { + self.init(.length(width), style, color) + } } diff --git a/Sources/CSS/Properties/BorderRadius.swift b/Sources/CSS/Properties/BorderRadius.swift index 5244f73..c4183e2 100644 --- a/Sources/CSS/Properties/BorderRadius.swift +++ b/Sources/CSS/Properties/BorderRadius.swift @@ -53,6 +53,15 @@ public struct BorderRadius: Property { self.isImportant = false } + public init( + _ v1: UnitRepresentable, + _ v2: UnitRepresentable? = nil, + _ v3: UnitRepresentable? = nil, + _ v4: UnitRepresentable? = nil + ) { + self.init(.length(v1, v2, v3, v4)) + } + // @TODO: better API for all value cases // https://www.w3schools.com/cssref/css3_pr_border-radius.asp } diff --git a/Sources/CSS/Properties/BorderRight.swift b/Sources/CSS/Properties/BorderRight.swift index 85efc58..a6b4ade 100644 --- a/Sources/CSS/Properties/BorderRight.swift +++ b/Sources/CSS/Properties/BorderRight.swift @@ -19,4 +19,20 @@ public struct BorderRight: Property { self.value = value.rawValue self.isImportant = false } + + public init( + _ width: BorderWidth.Value, + _ style: BorderStyle.Value? = nil, + _ color: CSSColor? = nil + ) { + self.init(.values(width, style, color)) + } + + public init( + _ width: UnitRepresentable, + _ style: BorderStyle.Value? = nil, + _ color: CSSColor? = nil + ) { + self.init(.length(width), style, color) + } } diff --git a/Sources/CSS/Properties/BorderTop.swift b/Sources/CSS/Properties/BorderTop.swift index 8dfa491..243af8b 100644 --- a/Sources/CSS/Properties/BorderTop.swift +++ b/Sources/CSS/Properties/BorderTop.swift @@ -19,4 +19,20 @@ public struct BorderTop: Property { self.value = value.rawValue self.isImportant = false } + + public init( + _ width: BorderWidth.Value, + _ style: BorderStyle.Value? = nil, + _ color: CSSColor? = nil + ) { + self.init(.values(width, style, color)) + } + + public init( + _ width: UnitRepresentable, + _ style: BorderStyle.Value? = nil, + _ color: CSSColor? = nil + ) { + self.init(.length(width), style, color) + } } diff --git a/Sources/CSS/Properties/Bottom.swift b/Sources/CSS/Properties/Bottom.swift index 6fef85f..1091220 100644 --- a/Sources/CSS/Properties/Bottom.swift +++ b/Sources/CSS/Properties/Bottom.swift @@ -7,6 +7,7 @@ /// CSS `bottom` property. /// Provides typed values for this declaration. public struct Bottom: Property { + /// Value options for the `bottom` property. public enum Value: Sendable { /// Lets the browser calculate the bottom edge position. This is default. @@ -45,4 +46,10 @@ public struct Bottom: Property { self.value = value.rawValue self.isImportant = false } + + public init( + _ value: UnitRepresentable + ) { + self.init(.length(value)) + } } diff --git a/Sources/CSS/Properties/Flex.swift b/Sources/CSS/Properties/Flex.swift index 14f13d9..466ae66 100644 --- a/Sources/CSS/Properties/Flex.swift +++ b/Sources/CSS/Properties/Flex.swift @@ -9,7 +9,7 @@ public struct Flex: Property { /// Value options for the `flex` property. public enum Value: Sendable { - case values(FlexGrow.Value, FlexShrink.Value, FlexBasis.Value) + case values(FlexGrow.Value, FlexShrink.Value?, FlexBasis.Value?) /// Same as 1 1 auto. case auto /// Same as 0 0 auto. @@ -22,7 +22,8 @@ public struct Flex: Property { var rawValue: String { switch self { case .values(let grow, let shrink, let basis): - return [grow.rawValue, shrink.rawValue, basis.rawValue] + return [grow.rawValue, shrink?.rawValue, basis?.rawValue] + .compactMap { $0 } .joined(separator: " ") case .auto: return "auto" @@ -57,9 +58,17 @@ public struct Flex: Property { /// - basis: The basis value. public init( _ grow: FlexGrow.Value, - _ shrink: FlexShrink.Value, - _ basis: FlexBasis.Value + _ shrink: FlexShrink.Value? = nil, + _ basis: FlexBasis.Value? = nil ) { self.init(.values(grow, shrink, basis)) } + + public init( + _ grow: Int, + _ shrink: FlexShrink.Value? = nil, + _ basis: FlexBasis.Value? = nil + ) { + self.init(.number(grow), shrink, basis) + } } diff --git a/Sources/CSS/Properties/FontFamily.swift b/Sources/CSS/Properties/FontFamily.swift index f961f03..3201ec0 100644 --- a/Sources/CSS/Properties/FontFamily.swift +++ b/Sources/CSS/Properties/FontFamily.swift @@ -41,4 +41,16 @@ public struct FontFamily: Property { self.value = value.rawValue self.isImportant = false } + + public init( + _ values: [String] + ) { + self.init(.family(values.joined(separator: ","))) + } + + public init( + _ values: String... + ) { + self.init(values) + } } diff --git a/Sources/CSS/Properties/FontSize.swift b/Sources/CSS/Properties/FontSize.swift index 2d8bbb5..c56d31f 100644 --- a/Sources/CSS/Properties/FontSize.swift +++ b/Sources/CSS/Properties/FontSize.swift @@ -7,6 +7,7 @@ /// CSS `font-size` property. /// Provides typed values for this declaration. public struct FontSize: Property { + /// Value options for the `font-size` property. public enum Value: Sendable { /// Sets the font-size to a medium size. This is default. @@ -77,4 +78,10 @@ public struct FontSize: Property { self.value = value.rawValue self.isImportant = false } + + public init( + _ value: UnitRepresentable + ) { + self.init(.length(value)) + } } diff --git a/Sources/CSS/Properties/Gap.swift b/Sources/CSS/Properties/Gap.swift index e54c881..740f0ab 100644 --- a/Sources/CSS/Properties/Gap.swift +++ b/Sources/CSS/Properties/Gap.swift @@ -16,9 +16,21 @@ public struct Gap: Property { /// - Parameters: /// - row: The row value. /// - col: The col value. - public init(_ row: RowGap.Value, _ col: ColumnGap.Value) { + public init( + _ row: RowGap.Value, + _ col: ColumnGap.Value? = nil + ) { self.name = "gap" - self.value = [row.rawValue, col.rawValue].joined(separator: " ") + self.value = [row.rawValue, col?.rawValue] + .compactMap { $0 } + .joined(separator: " ") self.isImportant = false } + + public init( + _ row: UnitRepresentable, + _ col: UnitRepresentable? = nil + ) { + self.init(.length(row), col.map { .length($0) }) + } } diff --git a/Sources/CSS/Properties/GridColumn.swift b/Sources/CSS/Properties/GridColumn.swift index 4aa5a5a..73786bb 100644 --- a/Sources/CSS/Properties/GridColumn.swift +++ b/Sources/CSS/Properties/GridColumn.swift @@ -16,9 +16,23 @@ public struct GridColumn: Property { /// - Parameters: /// - start: The start value. /// - end: The end value. - public init(_ start: GridColumnStart.Value, _ end: GridColumnEnd.Value) { + public init( + _ start: GridColumnStart.Value, + _ end: GridColumnEnd.Value? = nil + ) { self.name = "grid-column" - self.value = start.rawValue + " / " + end.rawValue + var value = start.rawValue + if let end { + value = value + " / " + end.rawValue + } + self.value = value self.isImportant = false } + + public init( + _ start: Int, + _ end: Int? = nil + ) { + self.init(.columnLine(start), end.map { .columnLine($0) }) + } } diff --git a/Sources/CSS/Properties/GridRow.swift b/Sources/CSS/Properties/GridRow.swift index f98f196..1c5a211 100644 --- a/Sources/CSS/Properties/GridRow.swift +++ b/Sources/CSS/Properties/GridRow.swift @@ -16,9 +16,23 @@ public struct GridRow: Property { /// - Parameters: /// - start: The start value. /// - end: The end value. - public init(_ start: GridRowStart.Value, _ end: GridRowEnd.Value) { + public init( + _ start: GridRowStart.Value, + _ end: GridRowEnd.Value? = nil + ) { self.name = "grid-row" - self.value = start.rawValue + " / " + end.rawValue + var value = start.rawValue + if let end { + value = value + " / " + end.rawValue + } + self.value = value self.isImportant = false } + + public init( + _ start: Int, + _ end: Int? = nil + ) { + self.init(.rowLine(start), end.map { .rowLine($0) }) + } } diff --git a/Sources/CSS/Properties/Left.swift b/Sources/CSS/Properties/Left.swift index 53befe7..4e8dc44 100644 --- a/Sources/CSS/Properties/Left.swift +++ b/Sources/CSS/Properties/Left.swift @@ -45,4 +45,10 @@ public struct Left: Property { self.value = value.rawValue self.isImportant = false } + + public init( + _ value: UnitRepresentable + ) { + self.init(.length(value)) + } } diff --git a/Sources/CSS/Properties/LetterSpacing.swift b/Sources/CSS/Properties/LetterSpacing.swift index 9af4113..405f626 100644 --- a/Sources/CSS/Properties/LetterSpacing.swift +++ b/Sources/CSS/Properties/LetterSpacing.swift @@ -45,4 +45,10 @@ public struct LetterSpacing: Property { self.value = value.rawValue self.isImportant = false } + + public init( + _ unit: UnitRepresentable + ) { + self.init(.length(unit)) + } } diff --git a/Sources/CSS/Properties/LineHeight.swift b/Sources/CSS/Properties/LineHeight.swift index 7cd51be..8bb2550 100644 --- a/Sources/CSS/Properties/LineHeight.swift +++ b/Sources/CSS/Properties/LineHeight.swift @@ -7,6 +7,7 @@ /// CSS `line-height` property. /// Provides typed values for this declaration. public struct LineHeight: Property { + /// Value options for the `line-height` property. public enum Value: Sendable { /// A normal line height. This is default. @@ -49,4 +50,17 @@ public struct LineHeight: Property { self.value = value.rawValue self.isImportant = false } + + public init( + _ value: Double + ) { + self.init(.number(value)) + } + + public init( + _ unit: Unit + ) { + self.init(.length(unit)) + } + } diff --git a/Sources/CSS/Properties/ListStyle.swift b/Sources/CSS/Properties/ListStyle.swift index 4d898c0..7a0552a 100644 --- a/Sources/CSS/Properties/ListStyle.swift +++ b/Sources/CSS/Properties/ListStyle.swift @@ -14,8 +14,8 @@ public struct ListStyle: Property { /// list-style-image Specifies the type of list-item marker. Default value is "none". case values( ListStyleType.Value, - ListStylePosition.Value, - ListStyleImage.Value + ListStylePosition.Value?, + ListStyleImage.Value? ) /// Sets this property to its default value. Read about initial. case initial @@ -25,7 +25,8 @@ public struct ListStyle: Property { var rawValue: String { switch self { case .values(let type, let position, let image): - return [type.rawValue, position.rawValue, image.rawValue] + return [type.rawValue, position?.rawValue, image?.rawValue] + .compactMap { $0 } .joined(separator: " ") case .initial: return "initial" @@ -48,4 +49,12 @@ public struct ListStyle: Property { self.value = value.rawValue self.isImportant = false } + + public init( + _ type: ListStyleType.Value, + _ position: ListStylePosition.Value? = nil, + _ image: ListStyleImage.Value? = nil + ) { + self.init(.values(type, position, image)) + } } diff --git a/Sources/CSS/Properties/Margin.swift b/Sources/CSS/Properties/Margin.swift index e14a7b0..7986327 100644 --- a/Sources/CSS/Properties/Margin.swift +++ b/Sources/CSS/Properties/Margin.swift @@ -7,6 +7,7 @@ /// CSS `margin` property. /// Provides typed values for this declaration. public struct Margin: Property { + /// Value options for the `margin` property. public enum Value: Sendable { /// Specifies a fixed bottom margin in px, cm, em, etc. Default value is 0. Negative values are allowed. diff --git a/Sources/CSS/Properties/OutlineWidth.swift b/Sources/CSS/Properties/OutlineWidth.swift index 2c6e46b..7021200 100644 --- a/Sources/CSS/Properties/OutlineWidth.swift +++ b/Sources/CSS/Properties/OutlineWidth.swift @@ -53,4 +53,8 @@ public struct OutlineWidth: Property { self.value = value.rawValue self.isImportant = false } + + public init(_ value: UnitRepresentable) { + self.init(.length(value)) + } } diff --git a/Sources/CSS/Properties/TextDecoration.swift b/Sources/CSS/Properties/TextDecoration.swift index 42b2b6b..0973f9b 100644 --- a/Sources/CSS/Properties/TextDecoration.swift +++ b/Sources/CSS/Properties/TextDecoration.swift @@ -7,6 +7,7 @@ /// CSS `text-decoration` property. /// Provides typed values for this declaration. public struct TextDecoration: Property { + /// Value options for the `text-decoration` property. public enum Value: Sendable { /// text-decoration-line Sets the kind of text decoration to use (like underline, overline, line-through). @@ -14,8 +15,8 @@ public struct TextDecoration: Property { /// text-decoration-style Sets the style of the text decoration (like solid, wavy, dotted, dashed, double). case values( TextDecorationLine.Value, - TextDecorationColor.Value, - TextDecorationStyle.Value + TextDecorationColor.Value?, + TextDecorationStyle.Value? ) /// Sets this property to its default value. Read about initial. case initial @@ -25,7 +26,8 @@ public struct TextDecoration: Property { var rawValue: String { switch self { case .values(let line, let color, let style): - return [line.rawValue, color.rawValue, style.rawValue] + return [line.rawValue, color?.rawValue, style?.rawValue] + .compactMap { $0 } .joined(separator: " ") case .initial: return "initial" @@ -49,4 +51,12 @@ public struct TextDecoration: Property { self.value = value.rawValue self.isImportant = false } + + public init( + _ line: TextDecorationLine.Value, + _ color: TextDecorationColor.Value? = nil, + _ style: TextDecorationStyle.Value? = nil + ) { + self.init(.values(line, color, style)) + } } diff --git a/Sources/CSS/Properties/_UnsafeRawProperty.swift b/Sources/CSS/Properties/_UnsafeRawProperty.swift new file mode 100644 index 0000000..4f95b19 --- /dev/null +++ b/Sources/CSS/Properties/_UnsafeRawProperty.swift @@ -0,0 +1,23 @@ +// +// File.swift +// swift-web-standards +// +// Created by Tibor Bödecs on 2026. 03. 06.. +// + +public struct UnsafeRawProperty: Property { + + public let name: String + public let value: String + public var isImportant: Bool + + public init( + name: String, + value: String, + isImportant: Bool = false + ) { + self.name = name + self.value = value + self.isImportant = isImportant + } +} diff --git a/Sources/CSS/Rules/Media.swift b/Sources/CSS/Rules/Media.swift index f072905..98e4174 100644 --- a/Sources/CSS/Rules/Media.swift +++ b/Sources/CSS/Rules/Media.swift @@ -221,10 +221,20 @@ public struct Media: Rule { /// - builder: Builder that returns selectors. public init( _ query: Query? = nil, - @Builder _ builder: () -> [Selector] + @Builder _ builder: () -> [Selector] = { [] } + ) { + self.init( + query: query, + selectors: builder() + ) + } + + public init( + query: Query? = nil, + selectors: [Selector] ) { self.query = query - self.selectors = builder() + self.selectors = selectors } } @@ -257,344 +267,346 @@ extension Media.Query { /// - Parameter value: The raw query string. /// - Returns: A media query matching the requested constraint. /// - Returns: A media query matching the requested constraint. - static func custom(_ value: String) -> Self { + public static func custom(_ value: String) -> Self { .init(value) } // devices /// Matches all devices. - static var all: Self { Media.Query.Device.all.query } + public static var all: Self { Media.Query.Device.all.query } /// Matches aural devices. - static var aural: Self { Media.Query.Device.aural.query } + public static var aural: Self { Media.Query.Device.aural.query } /// Matches braille devices. - static var braille: Self { Media.Query.Device.braille.query } + public static var braille: Self { Media.Query.Device.braille.query } /// Matches handheld devices. - static var handheld: Self { Media.Query.Device.handheld.query } + public static var handheld: Self { Media.Query.Device.handheld.query } /// Matches projection devices. - static var projection: Self { Media.Query.Device.projection.query } + public static var projection: Self { Media.Query.Device.projection.query } /// Matches print devices. - static var print: Self { Media.Query.Device.print.query } + public static var print: Self { Media.Query.Device.print.query } /// Matches screen devices. - static var screen: Self { Media.Query.Device.screen.query } + public static var screen: Self { Media.Query.Device.screen.query } /// Matches tty devices. - static var tty: Self { Media.Query.Device.tty.query } + public static var tty: Self { Media.Query.Device.tty.query } /// Matches TV devices. - static var tv: Self { Media.Query.Device.tv.query } + public static var tv: Self { Media.Query.Device.tv.query } // values /// Width equals a string value. /// - Parameter value: The width value. /// - Returns: A media query matching the requested constraint. - static func width(_ value: String) -> Self { + public static func width(_ value: String) -> Self { Media.Query.Value.width(.equals, value).query } /// Width equals a unit value. /// - Parameter unit: The width unit. /// - Returns: A media query matching the requested constraint. - static func width(_ unit: UnitRepresentable) -> Self { + public static func width(_ unit: UnitRepresentable) -> Self { .width(unit.rawValue) } /// Minimum width. /// - Parameter value: The width value. /// - Returns: A media query matching the requested constraint. - static func minWidth(_ value: String) -> Self { + public static func minWidth(_ value: String) -> Self { Media.Query.Value.width(.min, value).query } /// Minimum width from a unit. /// - Parameter unit: The width unit. /// - Returns: A media query matching the requested constraint. - static func minWidth(_ unit: UnitRepresentable) -> Self { + public static func minWidth(_ unit: UnitRepresentable) -> Self { .minWidth(unit.rawValue) } /// Maximum width. /// - Parameter value: The width value. /// - Returns: A media query matching the requested constraint. - static func maxWidth(_ value: String) -> Self { + public static func maxWidth(_ value: String) -> Self { Media.Query.Value.width(.max, value).query } /// Maximum width from a unit. /// - Parameter unit: The width unit. /// - Returns: A media query matching the requested constraint. - static func maxWidth(_ unit: UnitRepresentable) -> Self { + public static func maxWidth(_ unit: UnitRepresentable) -> Self { .maxWidth(unit.rawValue) } /// Height equals a string value. /// - Parameter value: The height value. /// - Returns: A media query matching the requested constraint. - static func height(_ value: String) -> Self { + public static func height(_ value: String) -> Self { Media.Query.Value.height(.equals, value).query } /// Height equals a unit value. /// - Parameter unit: The height unit. /// - Returns: A media query matching the requested constraint. - static func height(_ unit: UnitRepresentable) -> Self { + public static func height(_ unit: UnitRepresentable) -> Self { .height(unit.rawValue) } /// Minimum height. /// - Parameter value: The height value. /// - Returns: A media query matching the requested constraint. - static func minHeight(_ value: String) -> Self { + public static func minHeight(_ value: String) -> Self { Media.Query.Value.height(.min, value).query } /// Minimum height from a unit. /// - Parameter unit: The height unit. /// - Returns: A media query matching the requested constraint. - static func minHeight(_ unit: UnitRepresentable) -> Self { + public static func minHeight(_ unit: UnitRepresentable) -> Self { .minHeight(unit.rawValue) } /// Maximum height. /// - Parameter value: The height value. /// - Returns: A media query matching the requested constraint. - static func maxHeight(_ value: String) -> Self { + public static func maxHeight(_ value: String) -> Self { Media.Query.Value.height(.max, value).query } /// Maximum height from a unit. /// - Parameter unit: The height unit. /// - Returns: A media query matching the requested constraint. - static func maxHeight(_ unit: UnitRepresentable) -> Self { + public static func maxHeight(_ unit: UnitRepresentable) -> Self { .maxHeight(unit.rawValue) } /// Device width equals a string value. /// - Parameter value: The width value. /// - Returns: A media query matching the requested constraint. - static func deviceWidth(_ value: String) -> Self { + public static func deviceWidth(_ value: String) -> Self { Media.Query.Value.deviceWidth(.equals, value).query } /// Device width equals a unit value. /// - Parameter unit: The width unit. /// - Returns: A media query matching the requested constraint. - static func deviceWidth(_ unit: UnitRepresentable) -> Self { + public static func deviceWidth(_ unit: UnitRepresentable) -> Self { .deviceWidth(unit.rawValue) } /// Minimum device width. /// - Parameter value: The width value. /// - Returns: A media query matching the requested constraint. - static func deviceMinWidth(_ value: String) -> Self { + public static func deviceMinWidth(_ value: String) -> Self { Media.Query.Value.deviceWidth(.min, value).query } /// Minimum device width from a unit. /// - Parameter unit: The width unit. /// - Returns: A media query matching the requested constraint. - static func deviceMinWidth(_ unit: UnitRepresentable) -> Self { + public static func deviceMinWidth(_ unit: UnitRepresentable) -> Self { .deviceMinWidth(unit.rawValue) } /// Maximum device width. /// - Parameter value: The width value. /// - Returns: A media query matching the requested constraint. - static func deviceMaxWidth(_ value: String) -> Self { + public static func deviceMaxWidth(_ value: String) -> Self { Media.Query.Value.deviceWidth(.max, value).query } /// Maximum device width from a unit. /// - Parameter unit: The width unit. /// - Returns: A media query matching the requested constraint. - static func deviceMaxWidth(_ unit: UnitRepresentable) -> Self { + public static func deviceMaxWidth(_ unit: UnitRepresentable) -> Self { .deviceMaxWidth(unit.rawValue) } /// Device height equals a string value. /// - Parameter value: The height value. /// - Returns: A media query matching the requested constraint. - static func deviceHeight(_ value: String) -> Self { + public static func deviceHeight(_ value: String) -> Self { Media.Query.Value.deviceHeight(.equals, value).query } /// Device height equals a unit value. /// - Parameter unit: The height unit. /// - Returns: A media query matching the requested constraint. - static func deviceHeight(_ unit: UnitRepresentable) -> Self { + public static func deviceHeight(_ unit: UnitRepresentable) -> Self { .deviceHeight(unit.rawValue) } /// Minimum device height. /// - Parameter value: The height value. /// - Returns: A media query matching the requested constraint. - static func deviceMinHeight(_ value: String) -> Self { + public static func deviceMinHeight(_ value: String) -> Self { Media.Query.Value.deviceHeight(.min, value).query } /// Minimum device height from a unit. /// - Parameter unit: The height unit. /// - Returns: A media query matching the requested constraint. - static func deviceMinHeight(_ unit: UnitRepresentable) -> Self { + public static func deviceMinHeight(_ unit: UnitRepresentable) -> Self { .deviceMinHeight(unit.rawValue) } /// Maximum device height. /// - Parameter value: The height value. /// - Returns: A media query matching the requested constraint. - static func deviceMaxHeight(_ value: String) -> Self { + public static func deviceMaxHeight(_ value: String) -> Self { Media.Query.Value.deviceHeight(.max, value).query } /// Maximum device height from a unit. /// - Parameter unit: The height unit. /// - Returns: A media query matching the requested constraint. - static func deviceMaxHeight(_ unit: UnitRepresentable) -> Self { + public static func deviceMaxHeight(_ unit: UnitRepresentable) -> Self { .deviceMaxHeight(unit.rawValue) } /// Aspect ratio equals value. /// - Parameter value: The ratio value. /// - Returns: A media query matching the requested constraint. - static func aspectRatio(_ value: String) -> Self { + public static func aspectRatio(_ value: String) -> Self { Media.Query.Value.aspectRatio(.equals, value).query } /// Minimum aspect ratio. /// - Parameter value: The ratio value. /// - Returns: A media query matching the requested constraint. - static func minAspectRatio(_ value: String) -> Self { + public static func minAspectRatio(_ value: String) -> Self { Media.Query.Value.aspectRatio(.min, value).query } /// Maximum aspect ratio. /// - Parameter value: The ratio value. /// - Returns: A media query matching the requested constraint. - static func maxAspectRatio(_ value: String) -> Self { + public static func maxAspectRatio(_ value: String) -> Self { Media.Query.Value.aspectRatio(.max, value).query } /// Device aspect ratio equals value. /// - Parameter value: The ratio value. /// - Returns: A media query matching the requested constraint. - static func deviceAspectRatio(_ value: String) -> Self { + public static func deviceAspectRatio(_ value: String) -> Self { Media.Query.Value.deviceAspectRatio(.equals, value).query } /// Minimum device aspect ratio. /// - Parameter value: The ratio value. /// - Returns: A media query matching the requested constraint. - static func deviceMinAspectRatio(_ value: String) -> Self { + public static func deviceMinAspectRatio(_ value: String) -> Self { Media.Query.Value.deviceAspectRatio(.min, value).query } /// Maximum device aspect ratio. /// - Parameter value: The ratio value. /// - Returns: A media query matching the requested constraint. - static func deviceMaxAspectRatio(_ value: String) -> Self { + public static func deviceMaxAspectRatio(_ value: String) -> Self { Media.Query.Value.deviceAspectRatio(.max, value).query } /// Color depth equals value. /// - Parameter value: The color depth value. /// - Returns: A media query matching the requested constraint. - static func color(_ value: String) -> Self { + public static func color(_ value: String) -> Self { Media.Query.Value.color(.equals, value).query } /// Minimum color depth. /// - Parameter value: The color depth value. /// - Returns: A media query matching the requested constraint. - static func minColor(_ value: String) -> Self { + public static func minColor(_ value: String) -> Self { Media.Query.Value.color(.min, value).query } /// Maximum color depth. /// - Parameter value: The color depth value. /// - Returns: A media query matching the requested constraint. - static func maxColor(_ value: String) -> Self { + public static func maxColor(_ value: String) -> Self { Media.Query.Value.color(.max, value).query } /// Color index equals value. /// - Parameter value: The color index value. /// - Returns: A media query matching the requested constraint. - static func colorIndex(_ value: String) -> Self { + public static func colorIndex(_ value: String) -> Self { Media.Query.Value.colorIndex(.equals, value).query } /// Minimum color index. /// - Parameter value: The color index value. /// - Returns: A media query matching the requested constraint. - static func minColorIndex(_ value: String) -> Self { + public static func minColorIndex(_ value: String) -> Self { Media.Query.Value.colorIndex(.min, value).query } /// Maximum color index. /// - Parameter value: The color index value. /// - Returns: A media query matching the requested constraint. - static func maxColorIndex(_ value: String) -> Self { + public static func maxColorIndex(_ value: String) -> Self { Media.Query.Value.colorIndex(.max, value).query } /// Monochrome depth. /// - Parameter value: The monochrome depth value. /// - Returns: A media query matching the requested constraint. - static func monochrome(_ value: String) -> Self { + public static func monochrome(_ value: String) -> Self { Media.Query.Value.monochrome(value).query } /// Resolution equals value. /// - Parameter value: The resolution value. /// - Returns: A media query matching the requested constraint. - static func resolution(_ value: String) -> Self { + public static func resolution(_ value: String) -> Self { Media.Query.Value.resolution(.equals, value).query } /// Minimum resolution. /// - Parameter value: The resolution value. /// - Returns: A media query matching the requested constraint. - static func minResolution(_ value: String) -> Self { + public static func minResolution(_ value: String) -> Self { Media.Query.Value.resolution(.min, value).query } /// Maximum resolution. /// - Parameter value: The resolution value. /// - Returns: A media query matching the requested constraint. - static func maxResolution(_ value: String) -> Self { + public static func maxResolution(_ value: String) -> Self { Media.Query.Value.resolution(.max, value).query } /// Grid display type. /// - Parameter value: The grid value. /// - Returns: A media query matching the requested constraint. - static func grid(_ value: Media.Query.Grid) -> Self { + public static func grid(_ value: Media.Query.Grid) -> Self { Media.Query.Value.grid(value).query } /// Display mode. /// - Parameter value: The display mode value. /// - Returns: A media query matching the requested constraint. - static func displayMode(_ value: Media.Query.DisplayMode) -> Self { + public static func displayMode(_ value: Media.Query.DisplayMode) -> Self { Media.Query.Value.displayMode(value).query } /// Scan mode. /// - Parameter value: The scan mode value. /// - Returns: A media query matching the requested constraint. - static func scan(_ value: Media.Query.Scan) -> Self { + public static func scan(_ value: Media.Query.Scan) -> Self { Media.Query.Value.scan(value).query } /// Color scheme preference. /// - Parameter value: The color scheme value. /// - Returns: A media query matching the requested constraint. - static func prefersColorScheme(_ value: Media.Query.ColorScheme) -> Self { + public static func prefersColorScheme(_ value: Media.Query.ColorScheme) + -> Self + { Media.Query.Value.prefersColorScheme(value).query } /// Device orientation. /// - Parameter value: The orientation value. /// - Returns: A media query matching the requested constraint. - static func orientation(_ value: Media.Query.Orientation) -> Self { + public static func orientation(_ value: Media.Query.Orientation) -> Self { Media.Query.Value.orientation(value).query } } diff --git a/Sources/CSS/Selectors/Class.swift b/Sources/CSS/Selectors/Class.swift index 7f48434..f727a2c 100644 --- a/Sources/CSS/Selectors/Class.swift +++ b/Sources/CSS/Selectors/Class.swift @@ -21,9 +21,19 @@ public struct Class: Selector { public init( _ name: String, @Builder _ builder: () -> [any Property] + ) { + self.init( + name: name, + properties: builder() + ) + } + + public init( + name: String, + properties: [any Property] ) { self.name = "." + name - self.properties = builder() + self.properties = properties self.pseudo = nil } } diff --git a/Sources/CSS/Selectors/Element.swift b/Sources/CSS/Selectors/Element.swift deleted file mode 100644 index 872428f..0000000 --- a/Sources/CSS/Selectors/Element.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// Element.swift -// swift-web-standards -// -// Created by Binary Birds on 2026. 02. 02. - -/// CSS element selector (e.g. `body`, `p`). -public struct Element: Selector { - - /// Rendered selector name. - public let name: String - /// Properties attached to this selector. - public var properties: [any Property] - /// Optional pseudo selector suffix. - public var pseudo: String? = nil - - /// Creates an element selector. - /// - Parameters: - /// - name: The element tag name. - /// - builder: Builder that returns property declarations. - public init( - _ name: String, - @Builder _ builder: () -> [any Property] - ) { - self.name = name - self.properties = builder() - self.pseudo = nil - } -} diff --git a/Sources/HTML/Tags/MetaTag.swift b/Sources/HTML/Tags/MetaTag.swift index 1662a31..7e67b27 100644 --- a/Sources/HTML/Tags/MetaTag.swift +++ b/Sources/HTML/Tags/MetaTag.swift @@ -36,9 +36,11 @@ public struct Meta: self.attributes = .init() } - public enum NameAttributeValue: String, AttributeValueRepresentable { + public enum NameAttributeValue: RawRepresentable, + AttributeValueRepresentable + { /// Specifies the name of the Web application that the page represents. - case applicationName = "application-name" + case applicationName /// Specifies the name of the author of the document. case author /// Specifies a description of the page. Search engines can pick up this description to show with the results of searches. @@ -51,16 +53,88 @@ public struct Meta: case viewport /// robots. case robots - /// https://css-tricks.com/meta-theme-color-and-trickery/. - case colorScheme = "color-scheme" - case themeColor = "theme-color" - case appleMobileWebAppTitle = "apple-mobile-web-app-title" - case appleMobileWebAppCapable = "apple-mobile-web-app-capable" - case appleMobileWebAppStatusBarStyle = - "apple-mobile-web-app-status-bar-style" + case colorScheme + case themeColor + case appleMobileWebAppTitle + case appleMobileWebAppCapable + case appleMobileWebAppStatusBarStyle + case custom(String?) + + public init?(rawValue: String) { + switch rawValue { + case "application-name": + self = .applicationName + case "author": + self = .author + case "description": + self = .description + case "generator": + self = .generator + case "keywords": + self = .keywords + case "viewport": + self = .viewport + case "robots": + self = .robots + case "color-scheme": + self = .colorScheme + case "theme-color": + self = .themeColor + case "apple-mobile-web-app-title": + self = .appleMobileWebAppTitle + case "apple-mobile-web-app-capable": + self = .appleMobileWebAppCapable + case "apple-mobile-web-app-status-bar-style": + self = .appleMobileWebAppStatusBarStyle + default: + self = .custom(rawValue) + } + } + + public var rawValue: String { + switch self { + case .applicationName: + return "application-name" + case .author: + return "author" + case .description: + return "description" + case .generator: + return "generator" + case .keywords: + return "keywords" + case .viewport: + return "viewport" + case .robots: + return "robots" + case .colorScheme: + return "color-scheme" + case .themeColor: + return "theme-color" + case .appleMobileWebAppTitle: + return "apple-mobile-web-app-title" + case .appleMobileWebAppCapable: + return "apple-mobile-web-app-capable" + case .appleMobileWebAppStatusBarStyle: + return "apple-mobile-web-app-status-bar-style" + case .custom(let value): + return value ?? "" + } + } } public typealias NameAttributeValueType = NameAttributeValue + public func property( + _ value: String? + ) -> Self { + setAttribute(name: "property", value: value) + } + + public func name( + _ value: String? + ) -> Self { + name(.custom(value)) + } } diff --git a/Sources/SGML/Tags/BasicTag.swift b/Sources/SGML/Tags/BasicTag.swift index cfe9646..3c5e3bc 100644 --- a/Sources/SGML/Tags/BasicTag.swift +++ b/Sources/SGML/Tags/BasicTag.swift @@ -6,5 +6,5 @@ // public protocol BasicTag: Tag, Attributes { - + } diff --git a/Sources/SGML/Tags/ShortTag.swift b/Sources/SGML/Tags/ShortTag.swift index 9805d28..3aa1b4f 100644 --- a/Sources/SGML/Tags/ShortTag.swift +++ b/Sources/SGML/Tags/ShortTag.swift @@ -21,5 +21,3 @@ extension ShortTag { ) } } - - diff --git a/Sources/WebStandards/CamelToHyphens.swift b/Sources/WebStandards/CamelToHyphens.swift new file mode 100644 index 0000000..02f824b --- /dev/null +++ b/Sources/WebStandards/CamelToHyphens.swift @@ -0,0 +1,43 @@ +// +// File 2.swift +// puppylepsy-web-app +// +// Created by Tibor Bödecs on 2026. 03. 01.. +// + +func camelToHyphens( + _ string: String +) -> String { + guard !string.isEmpty else { return string } + + var result = "" + var previous: Character? + var iterator = string.makeIterator() + var current = iterator.next() + + while let char = current { + let next = iterator.next() + + if char.isUppercase { + if let prev = previous { + // Insert hyphen if: + // 1) previous is lowercase or digit (userID → user_id) + // 2) previous is uppercase AND next is lowercase (HTTPRequest → http_request) + if prev.isLowercase || prev.isNumber + || (prev.isUppercase && next?.isLowercase == true) + { + result.append("-") + } + } + result.append(char.lowercased()) + } + else { + result.append(char) + } + + previous = char + current = next + } + + return result +} diff --git a/Sources/WebStandards/Component.swift b/Sources/WebStandards/Component.swift new file mode 100644 index 0000000..a365b76 --- /dev/null +++ b/Sources/WebStandards/Component.swift @@ -0,0 +1,96 @@ +// +// File.swift +// dental-wiz-app +// +// Created by Tibor Bödecs on 2026. 03. 06.. +// + +import CSS +import DOM +import HTML +import SGML + +public protocol Component: SGML.Element { + associatedtype Content: SGML.BasicTag + + var identifier: String { get } + + var className: String { get } + + @CSS.Builder + func rules() -> [any CSS.Rule] + + @CSS.Builder + func selectors() -> [any CSS.Selector] + + @CSS.Builder + func properties() -> [any CSS.Property] + + // MARK: - html + + @ComponentContentBuilder + func content() -> Content +} + +extension Component { + + public var identifier: String { + String(describing: type(of: self)) + } + + public var className: String { + camelToHyphens(identifier) + } + + public func rules() -> [any CSS.Rule] { + let selectors = selectors() + guard !selectors.isEmpty else { + return [] + } + return [ + Media(selectors: selectors) + ] + } + + public func selectors() -> [any CSS.Selector] { + let properties = properties() + guard !properties.isEmpty else { + return [] + } + return [ + Class(name: className, properties: properties) + ] + } + + public func properties() -> [any CSS.Property] { + [] + } + + // MARK: - html body + + @ComponentContentBuilder + internal func htmlBody() -> some SGML.BasicTag { + let htmlBody = content() + + if properties().isEmpty { + htmlBody + } + else if let group = htmlBody as? ComponentGroup { + for child in group.children { + if let child = child as? any SGML.BasicTag { + child.addAttribute(name: "class", value: className) + } + else { + child + } + } + } + else { + htmlBody.addAttribute(name: "class", value: className) + } + } + + public var node: Node { + htmlBody().node + } +} diff --git a/Sources/WebStandards/ComponentContentBuilder.swift b/Sources/WebStandards/ComponentContentBuilder.swift new file mode 100644 index 0000000..343ee77 --- /dev/null +++ b/Sources/WebStandards/ComponentContentBuilder.swift @@ -0,0 +1,66 @@ +// +// File.swift +// puppylepsy-web-app +// +// Created by Tibor Bödecs on 2026. 03. 01.. +// + +import SGML + +@resultBuilder +public enum ComponentContentBuilder { + + public static func buildExpression( + _ expression: any Element + ) -> [any Element] { + [expression] + } + + public static func buildBlock( + _ components: [any Element]... + ) -> [any Element] { + components.flatMap { $0 } + } + + public static func buildOptional( + _ component: [any Element]? + ) -> [any Element] { + component ?? [] + } + + public static func buildEither( + first component: [any Element] + ) -> [any Element] { + component + } + + public static func buildEither( + second component: [any Element] + ) -> [any Element] { + component + } + + public static func buildArray( + _ components: [[any Element]] + ) -> [any Element] { + components.flatMap { $0 } + } + + public static func buildLimitedAvailability( + _ component: [any Element] + ) -> [any Element] { + component + } + + public static func buildExpression( + _ expression: ComponentGroup + ) -> [any Element] { + expression.children + } + + public static func buildFinalResult( + _ component: [any Element] + ) -> ComponentGroup { + .init(component) + } +} diff --git a/Sources/WebStandards/ComponentGroup.swift b/Sources/WebStandards/ComponentGroup.swift new file mode 100644 index 0000000..53fc76f --- /dev/null +++ b/Sources/WebStandards/ComponentGroup.swift @@ -0,0 +1,32 @@ +// +// File.swift +// swift-web-standards +// +// Created by Tibor Bödecs on 2026. 03. 06.. +// + +import DOM +import SGML + +public struct ComponentGroup: BasicTag, Container { + + public static var name: String { + fatalError("ComponentGroup should not be rendered.") + } + + public var attributes: AttributeStore = .init() + public var children: [any Element] + + init( + _ items: [any Element] + ) { + self.children = items + } + + public var node: any Node { + if children.count == 1, let first = children.first { + return first.node + } + return ListNode(items: children.map(\.node)) + } +} diff --git a/Sources/WebStandards/ComponentStylesheetCollector.swift b/Sources/WebStandards/ComponentStylesheetCollector.swift new file mode 100644 index 0000000..700dd41 --- /dev/null +++ b/Sources/WebStandards/ComponentStylesheetCollector.swift @@ -0,0 +1,53 @@ +import CSS +import SGML + +public struct ComponentStylesheetCollector: Sendable { + + private struct State { + var rulesByComponent: [String: [any CSS.Rule]] = [:] + var componentOrder: [String] = [] + var collectedComponents: Set = [] + } + + public init() { + + } + + public func getStylesheet( + from element: any SGML.Element + ) -> CSS.Stylesheet { + var state = State() + collectLocalComponentRules(from: element, state: &state) + let rules = state.componentOrder.flatMap { + state.rulesByComponent[$0] ?? [] + } + return CSS.Stylesheet(rules) + } + + private func collectLocalComponentRules( + from element: any SGML.Element, + state: inout State + ) { + if let component = element as? any Component { + collectLocalRules(from: component, state: &state) + } + if let container = element as? any SGML.Container { + for child in container.children { + collectLocalComponentRules(from: child, state: &state) + } + } + } + + private func collectLocalRules( + from component: any Component, + state: inout State + ) { + let identifier = component.identifier + guard state.collectedComponents.insert(identifier).inserted else { + return + } + state.rulesByComponent[identifier] = component.rules() + state.componentOrder.append(identifier) + collectLocalComponentRules(from: component.htmlBody(), state: &state) + } +} diff --git a/Sources/WebStandards/Empty.swift b/Sources/WebStandards/Empty.swift deleted file mode 100644 index 55ba07d..0000000 --- a/Sources/WebStandards/Empty.swift +++ /dev/null @@ -1,5 +0,0 @@ -// -// Empty.swift -// swift-web-standards -// -// Created by Binary Birds on 2026. 01. 05. diff --git a/Sources/WebStandards/GlobalStyleComponent.swift b/Sources/WebStandards/GlobalStyleComponent.swift new file mode 100644 index 0000000..85380a0 --- /dev/null +++ b/Sources/WebStandards/GlobalStyleComponent.swift @@ -0,0 +1,56 @@ +// +// File.swift +// puppylepsy-web-app +// +// Created by Tibor Bödecs on 2026. 03. 01.. +// + +import CSS +import DOM +import HTML +import SGML + +public protocol GlobalStyleComponent { + + static var className: String { get } + + @CSS.Builder + static func rules() -> [any CSS.Rule] + + @CSS.Builder + static func selectors() -> [any CSS.Selector] + + @CSS.Builder + static func properties() -> [any CSS.Property] +} + +extension GlobalStyleComponent { + + public static var className: String { + camelToHyphens(String(describing: self)) + } + + public static func rules() -> [any CSS.Rule] { + let selectors = Self.selectors() + guard !selectors.isEmpty else { + return [] + } + return [ + Media(selectors: selectors) + ] + } + + public static func selectors() -> [any CSS.Selector] { + let properties = Self.properties() + guard !properties.isEmpty else { + return [] + } + return [ + Class(name: Self.className, properties: properties) + ] + } + + public static func properties() -> [any CSS.Property] { + [] + } +} diff --git a/Sources/WebStandards/GlobalStylesheetCollector.swift b/Sources/WebStandards/GlobalStylesheetCollector.swift new file mode 100644 index 0000000..011931d --- /dev/null +++ b/Sources/WebStandards/GlobalStylesheetCollector.swift @@ -0,0 +1,30 @@ +import CSS + +public struct GlobalStylesheetCollector: Sendable { + + private var rulesByComponent: [String: [any CSS.Rule]] + private var componentOrder: [String] + + public init() { + self.rulesByComponent = [:] + self.componentOrder = [] + } + + public mutating func register( + _ component: T.Type + ) { + let identifier = String(describing: component) + guard rulesByComponent[identifier] == nil else { + return + } + rulesByComponent[identifier] = component.rules() + componentOrder.append(identifier) + } + + public func getGlobalStylesheet() -> CSS.Stylesheet { + let rules = componentOrder.flatMap { + rulesByComponent[$0] ?? [] + } + return CSS.Stylesheet(rules) + } +} diff --git a/Tests/CSSTests/Properties/BackdropFilterTestSuite.swift b/Tests/CSSTests/Properties/BackdropFilterTestSuite.swift index 967b9a4..cf0bafa 100644 --- a/Tests/CSSTests/Properties/BackdropFilterTestSuite.swift +++ b/Tests/CSSTests/Properties/BackdropFilterTestSuite.swift @@ -38,42 +38,27 @@ struct BackdropFilterTests { @Test func values() { - let blur = BackdropFilter(blur: 100.px) - let blurRem = BackdropFilter(blur: 2.rem) + let blur = BackdropFilter(.blur(100.px)) + let blurRem = BackdropFilter(.blur(2.rem)) let inherit = BackdropFilter(.inherit) let renderer = StylesheetRenderer() - if let blur { - #expect( - renderer.renderProperty(blur) - == "backdrop-filter: blur(100px)" - ) - } - else { - Issue.record( - "Expected px unit to be accepted by BackdropFilter blur." - ) - } - - if let blurRem { - #expect( - renderer.renderProperty(blurRem) - == "backdrop-filter: blur(2rem)" - ) - } - else { - Issue.record( - "Expected rem unit to be accepted by BackdropFilter blur." - ) - } - #expect(renderer.renderProperty(inherit) == "backdrop-filter: inherit") - } + #expect( + renderer.renderProperty(blur) == "backdrop-filter: blur(100px)" + ) - @Test - func unsupportedUnits() { - let invalid = BackdropFilter(blur: 50.percent) + #expect( + renderer.renderProperty(blurRem) == "backdrop-filter: blur(2rem)" + ) - #expect(invalid == nil) + #expect(renderer.renderProperty(inherit) == "backdrop-filter: inherit") } + + // @Test + // func unsupportedUnits() { + // let invalid = BackdropFilter(.blur(50.percent)) + // + // #expect(invalid == nil) + // } } diff --git a/Tests/CSSTests/SelectorBehaviorTests.swift b/Tests/CSSTests/SelectorBehaviorTests.swift index 4f47864..2c99a62 100644 --- a/Tests/CSSTests/SelectorBehaviorTests.swift +++ b/Tests/CSSTests/SelectorBehaviorTests.swift @@ -43,7 +43,7 @@ struct SelectorBehaviorTests { let idSelector = Id("hero") { BackgroundColor(.blue) } - let elementSelector = Element("p") { + let elementSelector = Custom("p") { Margin(8.px) } let universalSelector = Universal { diff --git a/Tests/CSSTests/StylesheetRendererTests.swift b/Tests/CSSTests/StylesheetRendererTests.swift index 910a22d..b096c7f 100644 --- a/Tests/CSSTests/StylesheetRendererTests.swift +++ b/Tests/CSSTests/StylesheetRendererTests.swift @@ -32,7 +32,7 @@ struct StylesheetRendererTests { func indentation() { let css = Stylesheet { Media { - Element("span") { + Custom("span") { Margin(4.px) } } diff --git a/Tests/CSSTests/SwiftCSSTests.swift b/Tests/CSSTests/SwiftCSSTests.swift index 63926cb..98e66b8 100644 --- a/Tests/CSSTests/SwiftCSSTests.swift +++ b/Tests/CSSTests/SwiftCSSTests.swift @@ -181,7 +181,7 @@ struct SwiftCssTests { } } Media(.screen && .displayMode(.standalone)) { - Element("body") { + Custom("body") { Background(color: .yellow) } } diff --git a/Tests/WebStandardsTests/WebStandardsTestSuite.swift b/Tests/WebStandardsTests/WebStandardsTestSuite.swift index 2537209..1c5fba8 100644 --- a/Tests/WebStandardsTests/WebStandardsTestSuite.swift +++ b/Tests/WebStandardsTests/WebStandardsTestSuite.swift @@ -4,6 +4,10 @@ // // Created by Binary Birds on 2026. 01. 05. +import CSS +import DOM +import HTML +import SGML import Testing @testable import WebStandards @@ -12,7 +16,241 @@ import Testing struct WebStandardsTestSuite { @Test - func example() async throws { + func camelToHyphensConvertsEdgeCases() { + #expect(camelToHyphens("") == "") + #expect(camelToHyphens("simpleTest") == "simple-test") + #expect(camelToHyphens("userID") == "user-id") + #expect(camelToHyphens("HTTPRequest") == "http-request") + #expect(camelToHyphens("model3DAsset") == "model3-d-asset") + } + + @Test + func componentDefaultsWithoutPropertiesProduceNoSelectorsOrRules() { + let component = NoStyleComponent() + + #expect(component.identifier == "NoStyleComponent") + #expect(component.className == "no-style-component") + #expect(component.properties().isEmpty) + #expect(component.selectors().isEmpty) + #expect(component.rules().isEmpty) + } + + @Test + func globalStyleDefaultsUseGeneratedClassName() { + #expect(GlobalPositionStyle.className == "global-position-style") + + let css = StylesheetRenderer(minify: true) + .render( + Stylesheet(GlobalPositionStyle.rules()) + ) + + #expect(css == ".global-position-style{position:relative}") + } + + @Test + func htmlBodyKeepsSingleChildWithoutClassInComponentGroupPath() { + let component = StyledParagraphComponent(text: "Hello") + let body = component.htmlBody() + + #expect(body.getAttribute(name: "class") == nil) + } + + @Test + func htmlBodyDoesNotAddClassWhenComponentHasNoProperties() { + let component = NoStyleComponent() + let body = component.htmlBody() + + #expect(body.getAttribute(name: "class") == nil) + } + + @Test + func htmlBodyAddsClassToEachChildWhenContentIsGroup() throws { + let component = StyledGroupComponent() + let body = component.htmlBody() + guard let group = body as? ComponentGroup else { + Issue.record("Expected ComponentGroup from multi-node content.") + return + } + + #expect(group.children.count == 2) + for child in group.children { + guard let tag = child as? any BasicTag else { + Issue.record("Expected BasicTag child in ComponentGroup.") + continue + } + #expect(tag.getAttribute(name: "class") == component.className) + } + } + + @Test + func componentGroupNodeRendersSingleAndMultipleChildren() { + let one = ComponentGroup([Span("one")]) + let many = ComponentGroup([Span("one"), Span("two")]) + + let oneRendered = SGML.Renderer() + .render( + document: Document(root: one) + ) + let manyRendered = SGML.Renderer() + .render( + document: Document(root: many) + ) + + #expect(!(one.node is ListNode)) + #expect(many.node is ListNode) + #expect(oneRendered == "one") + #expect(manyRendered == "onetwo") + } + + @Test + func componentContentBuilderSupportsCoreBuilderOperations() { + let a = ComponentContentBuilder.buildExpression(Span("a")) + let b = ComponentContentBuilder.buildExpression(Span("b")) + let nestedGroup = ComponentContentBuilder.buildExpression( + ComponentGroup([Span("x"), Span("y")]) + ) + let combined = ComponentContentBuilder.buildBlock(a, b, nestedGroup) + let optionalNone = ComponentContentBuilder.buildOptional(nil) + let optionalSome = ComponentContentBuilder.buildOptional(a) + let eitherFirst = ComponentContentBuilder.buildEither(first: a) + let eitherSecond = ComponentContentBuilder.buildEither(second: b) + let array = ComponentContentBuilder.buildArray([a, b]) + let availability = ComponentContentBuilder.buildLimitedAvailability(a) + let final = ComponentContentBuilder.buildFinalResult(combined) + + #expect(a.count == 1) + #expect(nestedGroup.count == 2) + #expect(combined.count == 4) + #expect(optionalNone.isEmpty) + #expect(optionalSome.count == 1) + #expect(eitherFirst.count == 1) + #expect(eitherSecond.count == 1) + #expect(array.count == 2) + #expect(availability.count == 1) + #expect(final.children.count == 4) + } + + @Test + func globalStylesheetCollectorDeduplicatesAndPreservesRegistrationOrder() { + var collector = GlobalStylesheetCollector() + collector.register(GlobalAbsoluteStyle.self) + collector.register(GlobalPositionStyle.self) + collector.register(GlobalAbsoluteStyle.self) + + let rendered = StylesheetRenderer(minify: true) + .render( + collector.getGlobalStylesheet() + ) + + #expect( + rendered + == ".global-absolute-style{position:absolute}.global-position-style{position:relative}" + ) + } + + @Test + func + componentStylesheetCollectorCollectsNestedComponentsOnceInTraversalOrder() + throws + { + let root = Div { + StyledParentComponent() + StyledLeafComponent() + } + + let collector = ComponentStylesheetCollector() + let rendered = StylesheetRenderer(minify: true) + .render( + collector.getStylesheet(from: root) + ) + #expect( + rendered + == ".styled-parent-component{position:relative}.styled-leaf-component{margin:0 0 1px 0}" + ) + } + + @Test + func + componentStylesheetCollectorIncludesNestedComponentsFromRootWithoutOwnRules() + { + let collector = ComponentStylesheetCollector() + let rendered = StylesheetRenderer(minify: true) + .render( + collector.getStylesheet(from: NoStyleParentComponent()) + ) + + #expect(rendered == ".styled-leaf-component{margin:0 0 1px 0}") + } +} + +private struct NoStyleComponent: Component, FlowContent { + func content() -> some BasicTag { + P("no-style") + } +} + +private struct StyledParagraphComponent: Component, FlowContent { + let text: String + + func properties() -> [any CSS.Property] { + Position(.relative) + } + + func content() -> some BasicTag { + P(text) + } +} + +private struct StyledGroupComponent: Component, FlowContent { + func properties() -> [any CSS.Property] { + Position(.relative) + } + + func content() -> some BasicTag { + P("first") + Span("second") + } +} + +private struct StyledLeafComponent: Component, FlowContent { + func properties() -> [any CSS.Property] { + Margin(bottom: 1.px) + } + + func content() -> some BasicTag { + Span("leaf") + } +} + +private struct StyledParentComponent: Component, FlowContent { + func properties() -> [any CSS.Property] { + Position(.relative) + } + + func content() -> some BasicTag { + Div { + StyledLeafComponent() + StyledLeafComponent() + } + } +} + +private struct NoStyleParentComponent: Component, FlowContent { + func content() -> some BasicTag { + Div { + StyledLeafComponent() + } + } +} + +private struct GlobalPositionStyle: GlobalStyleComponent { + static func properties() -> [any CSS.Property] { + Position(.relative) + } +} +private struct GlobalAbsoluteStyle: GlobalStyleComponent { + static func properties() -> [any CSS.Property] { + Position(.absolute) } } diff --git a/swift-web-standards-Package.xctestplan b/swift-web-standards-Package.xctestplan deleted file mode 100644 index 6b55061..0000000 --- a/swift-web-standards-Package.xctestplan +++ /dev/null @@ -1,90 +0,0 @@ -{ - "configurations" : [ - { - "id" : "13942CE1-C703-4B5F-A358-8CB94AF25581", - "name" : "Test Scheme Action", - "options" : { - - } - } - ], - "defaultOptions" : { - "performanceAntipatternCheckerEnabled" : true, - "testExecutionOrdering" : "random" - }, - "testTargets" : [ - { - "parallelizable" : true, - "target" : { - "containerPath" : "container:", - "identifier" : "CSSTests", - "name" : "CSSTests" - } - }, - { - "parallelizable" : true, - "target" : { - "containerPath" : "container:", - "identifier" : "DOMTests", - "name" : "DOMTests" - } - }, - { - "parallelizable" : true, - "target" : { - "containerPath" : "container:", - "identifier" : "HTMLTests", - "name" : "HTMLTests" - } - }, - { - "parallelizable" : true, - "target" : { - "containerPath" : "container:", - "identifier" : "MIMETests", - "name" : "MIMETests" - } - }, - { - "parallelizable" : true, - "target" : { - "containerPath" : "container:", - "identifier" : "RSSTests", - "name" : "RSSTests" - } - }, - { - "parallelizable" : true, - "target" : { - "containerPath" : "container:", - "identifier" : "SGMLTests", - "name" : "SGMLTests" - } - }, - { - "parallelizable" : true, - "target" : { - "containerPath" : "container:", - "identifier" : "SVGTests", - "name" : "SVGTests" - } - }, - { - "parallelizable" : true, - "target" : { - "containerPath" : "container:", - "identifier" : "SitemapTests", - "name" : "SitemapTests" - } - }, - { - "parallelizable" : true, - "target" : { - "containerPath" : "container:", - "identifier" : "WebStandardsTests", - "name" : "WebStandardsTests" - } - } - ], - "version" : 1 -}