diff --git a/.github/pages/src/components/ConfigSection.svelte b/.github/pages/src/components/ConfigSection.svelte index 2adf292..bc27c42 100644 --- a/.github/pages/src/components/ConfigSection.svelte +++ b/.github/pages/src/components/ConfigSection.svelte @@ -9,19 +9,31 @@

Configuration

{#each properties as prop} + {#if prop.type === 'boolean'} +
+
+ + {#if prop.description} +

{prop.description}

+ {/if} +
+ +
+ {:else}
- {#if prop.type === 'boolean'} - setConfig(prop.key, e.target.checked)} - /> - {:else if prop.type === 'number'} + {#if prop.type === 'number'} {/if}
+ {/if} {/each}
diff --git a/.github/pages/src/lib/state.svelte.js b/.github/pages/src/lib/state.svelte.js index 137e88d..e5faf67 100644 --- a/.github/pages/src/lib/state.svelte.js +++ b/.github/pages/src/lib/state.svelte.js @@ -25,9 +25,12 @@ const jsonOutput = $derived.by(() => { obj.styles = stylesObj; } - // Build config — only include if it has properties - if (Object.keys(config).length > 0) { - obj.config = { ...config }; + // Build config — omit default/empty values (false booleans, empty strings) + const configCopy = Object.fromEntries( + Object.entries(config).filter(([_, v]) => v !== false && v !== '') + ); + if (Object.keys(configCopy).length > 0) { + obj.config = configCopy; } return JSON.stringify(obj, null, 2); diff --git a/.github/pages/src/styles/app.css b/.github/pages/src/styles/app.css index fab7105..06f9f63 100644 --- a/.github/pages/src/styles/app.css +++ b/.github/pages/src/styles/app.css @@ -193,6 +193,18 @@ section h2 { color: var(--color-danger); } +.config-field-bool { + flex-direction: row; + align-items: center; + justify-content: space-between; +} + +.config-field-desc { + font-size: 0.75rem; + color: var(--color-text-muted); + margin-top: 0.15rem; +} + .config-field input[type='text'], .config-field input[type='number'] { padding: 0.5rem 0.65rem; diff --git a/README.md b/README.md index fa53154..fb40586 100644 --- a/README.md +++ b/README.md @@ -55,11 +55,17 @@ Only include the categories you need — the generated `Theme` struct will match "gradients": [ { "name": "primary", "style": "primaryGradient" } ] + }, + "config": { + "outputPath": ".", + "shouldGeneratePreview": true } } ``` > Use the object form `{ "name": ..., "style": ... }` when a token name conflicts with a SwiftUI built-in (e.g. `primary` → `primaryColor`). +> +> Set `"shouldGeneratePreview": true` in the config section to generate a `Theme+Preview.swift` file with a SwiftUI view that displays all your tokens as a visual palette. ### 3. Generate theme files @@ -75,6 +81,7 @@ Right-click your project in the Xcode navigator → **Generate Theme Files**. - `Environment+Theme.swift` for environment plumbing - `copyWith` helpers for immutable updates - A `Theme+Defaults.swift` scaffold for you to fill in +- A `Theme+Preview.swift` file with a visual preview of all tokens (only when `shouldGeneratePreview` is enabled) diff --git a/Sources/ThemeKitGenerator/ThemeConfig.swift b/Sources/ThemeKitGenerator/ThemeConfig.swift index c8d9a9f..454dd03 100644 --- a/Sources/ThemeKitGenerator/ThemeConfig.swift +++ b/Sources/ThemeKitGenerator/ThemeConfig.swift @@ -13,8 +13,12 @@ nonisolated public struct ThemeFile: Sendable, Codable, Equatable { /// Relative path from the generation root where generated files should be written. public let outputPath: String - nonisolated public init(outputPath: String) { + /// When true, generates a Theme+Preview.swift file containing a SwiftUI preview view. + public let shouldGeneratePreview: Bool? + + nonisolated public init(outputPath: String, shouldGeneratePreview: Bool? = nil) { self.outputPath = outputPath + self.shouldGeneratePreview = shouldGeneratePreview } } @@ -27,6 +31,11 @@ nonisolated public struct ThemeFile: Sendable, Codable, Equatable { nonisolated public var resolvedOutputPath: String { config?.outputPath ?? "." } + + /// Whether to generate a preview file, defaulting to false. + nonisolated public var shouldGeneratePreview: Bool { + config?.shouldGeneratePreview ?? false + } } nonisolated public struct ThemeConfig: Sendable, Codable, Equatable { diff --git a/Sources/ThemeKitGenerator/ThemeFileGenerator.swift b/Sources/ThemeKitGenerator/ThemeFileGenerator.swift index 8dbbc90..2be8f69 100644 --- a/Sources/ThemeKitGenerator/ThemeFileGenerator.swift +++ b/Sources/ThemeKitGenerator/ThemeFileGenerator.swift @@ -39,7 +39,13 @@ nonisolated public struct ThemeFileGenerator: Sendable { nonisolated public func generate(fromJSON data: Data) throws -> (files: [GeneratedFile], outputPath: String) { let themeFile = try JSONDecoder().decode(ThemeFile.self, from: data) - let files = generate(from: themeFile.styles) + var files = generate(from: themeFile.styles) + + // Conditionally add preview file + if themeFile.shouldGeneratePreview { + files.append(ThemePreviewGenerator().generate(from: themeFile.styles)) + } + return (files, themeFile.resolvedOutputPath) } } diff --git a/Sources/ThemeKitGenerator/ThemePreviewGenerator.swift b/Sources/ThemeKitGenerator/ThemePreviewGenerator.swift new file mode 100644 index 0000000..18c1944 --- /dev/null +++ b/Sources/ThemeKitGenerator/ThemePreviewGenerator.swift @@ -0,0 +1,191 @@ +nonisolated public struct ThemePreviewGenerator: Sendable { + + nonisolated public init() {} + + nonisolated public func generate(from config: ThemeConfig) -> GeneratedFile { + var sections: [String] = [] + var components: [String] = [] + + // Generate color swatches if colors present + if config.categories.contains(.colors) { + let tokens = ThemeCategory.colors.tokens(from: config) + sections.append(colorSwatchesSection(tokens: tokens)) + components.append(colorSwatchComponent()) + } + + // Generate gradient strips if gradients present + if config.categories.contains(.gradients) { + let tokens = ThemeCategory.gradients.tokens(from: config) + sections.append(gradientStripsSection(tokens: tokens)) + components.append(gradientStripComponent()) + } + + // Generate shadow showcase if shadows present + if config.categories.contains(.shadows) { + let tokens = ThemeCategory.shadows.tokens(from: config) + sections.append(shadowShowcaseSection(tokens: tokens)) + components.append(shadowCardComponent()) + } + + let bodySections = sections.joined(separator: "\n\n") + + var componentsSuffix = "" + if !components.isEmpty { + componentsSuffix = "\n\n// MARK: - Preview Components\n\n" + components.joined(separator: "\n\n") + } + + let content = """ + // Generated by ThemeKit — do not edit + + import SwiftUI + import ThemeKit + + /// A SwiftUI view that renders all theme tokens as a visual palette. + public struct ThemePreview: View { + + public var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + \(bodySections.split(separator: "\n").map { " \($0)" }.joined(separator: "\n")) + } + .padding() + } + } + } + + #Preview { + ThemePreview() + }\(componentsSuffix) + + """ + return GeneratedFile(name: "Theme+Preview.swift", content: content) + } + + // MARK: - Sections + + nonisolated func colorSwatchesSection(tokens: [ThemeToken]) -> String { + let swatches = tokens.map { token in + """ + ColorSwatch(name: "\(token.name)", style: .\(token.style)) + """ + }.joined(separator: "\n") + + return """ + // MARK: - Colors + VStack(alignment: .leading, spacing: 12) { + Text("Colors") + .font(.headline) + LazyVGrid(columns: [GridItem(.adaptive(minimum: 96))], spacing: 12) { + \(swatches) + } + } + """ + } + + nonisolated func gradientStripsSection(tokens: [ThemeToken]) -> String { + let strips = tokens.map { token in + """ + GradientStrip(name: "\(token.name)", style: .\(token.style)) + """ + }.joined(separator: "\n") + + return """ + // MARK: - Gradients + VStack(alignment: .leading, spacing: 12) { + Text("Gradients") + .font(.headline) + LazyVGrid(columns: [GridItem(.adaptive(minimum: 96))], spacing: 12) { + \(strips) + } + } + """ + } + + nonisolated func shadowShowcaseSection(tokens: [ThemeToken]) -> String { + let cards = tokens.map { token in + """ + ShadowCard(name: "\(token.name)", style: .background.\(token.style)) + """ + }.joined(separator: "\n") + + return """ + // MARK: - Shadows + VStack(alignment: .leading, spacing: 12) { + Text("Shadows") + .font(.headline) + LazyVGrid(columns: [GridItem(.adaptive(minimum: 120))], spacing: 12) { + \(cards) + } + } + """ + } + + // MARK: - Components + + nonisolated func colorSwatchComponent() -> String { + """ + private struct ColorSwatch: View { + let name: String + let style: Style + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + RoundedRectangle(cornerRadius: 8) + .fill(style) + .frame(height: 32) + + Text(name) + .font(.caption) + .lineLimit(1) + .foregroundStyle(.secondary) + } + } + } + """ + } + + nonisolated func gradientStripComponent() -> String { + """ + private struct GradientStrip: View { + let name: String + let style: Style + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + RoundedRectangle(cornerRadius: 8) + .fill(style) + .frame(height: 32) + + Text(name) + .font(.caption) + .lineLimit(1) + .foregroundStyle(.secondary) + } + } + } + """ + } + + nonisolated func shadowCardComponent() -> String { + """ + private struct ShadowCard: View { + let name: String + let style: Style + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + RoundedRectangle(cornerRadius: 12) + .fill(style) + .frame(height: 48) + + Text(name) + .font(.caption) + .lineLimit(1) + .foregroundStyle(.secondary) + } + } + } + """ + } + +} diff --git a/Tests/ThemeKitGeneratorTests/ThemeFileGeneratorTests.swift b/Tests/ThemeKitGeneratorTests/ThemeFileGeneratorTests.swift index 24e1a79..7796828 100644 --- a/Tests/ThemeKitGeneratorTests/ThemeFileGeneratorTests.swift +++ b/Tests/ThemeKitGeneratorTests/ThemeFileGeneratorTests.swift @@ -332,4 +332,74 @@ struct ThemeFileGeneratorTests { let gradientsExt = try #require(files.first { $0.name == "ShapeStyle+ThemeGradients.swift" }) #expect(!gradientsExt.content.contains("ThemeShadowedStyle")) } + + // MARK: - Preview generation + + @Test func shouldGeneratePreview_false_doesNotGeneratePreviewFile() throws { + let jsonWithoutPreview = Data(""" + { + "styles": { + "colors": ["surface"] + }, + "config": { + "outputPath": ".", + "shouldGeneratePreview": false + } + } + """.utf8) + + let files = try ThemeFileGenerator().generate(fromJSON: jsonWithoutPreview).files + let names = Set(files.map(\.name)) + + #expect(!names.contains("Theme+Preview.swift")) + } + + @Test func shouldGeneratePreview_true_generatesPreviewFile() throws { + let jsonWithPreview = Data(""" + { + "styles": { + "colors": ["surface", "primary"] + }, + "config": { + "outputPath": ".", + "shouldGeneratePreview": true + } + } + """.utf8) + + let files = try ThemeFileGenerator().generate(fromJSON: jsonWithPreview).files + let names = Set(files.map(\.name)) + + #expect(names.contains("Theme+Preview.swift")) + } + + @Test func shouldGeneratePreview_omitted_doesNotGeneratePreviewFile() throws { + let files = try ThemeFileGenerator().generate(fromJSON: colorsOnlyJSON).files + let names = Set(files.map(\.name)) + + #expect(!names.contains("Theme+Preview.swift")) + } + + @Test func previewFile_containsCorrectStructure() throws { + let jsonWithPreview = Data(""" + { + "styles": { + "colors": ["surface"], + "gradients": ["primary"] + }, + "config": { + "outputPath": ".", + "shouldGeneratePreview": true + } + } + """.utf8) + + let files = try ThemeFileGenerator().generate(fromJSON: jsonWithPreview).files + let previewFile = try #require(files.first { $0.name == "Theme+Preview.swift" }) + + #expect(previewFile.content.contains("public struct ThemePreview: View")) + #expect(previewFile.content.contains("#Preview")) + #expect(previewFile.content.contains("// MARK: - Colors")) + #expect(previewFile.content.contains("// MARK: - Gradients")) + } } diff --git a/Tests/ThemeKitGeneratorTests/ThemePreviewGeneratorTests.swift b/Tests/ThemeKitGeneratorTests/ThemePreviewGeneratorTests.swift new file mode 100644 index 0000000..a2dc360 --- /dev/null +++ b/Tests/ThemeKitGeneratorTests/ThemePreviewGeneratorTests.swift @@ -0,0 +1,160 @@ +import Testing +import Foundation +@testable import ThemeKitGenerator + +@Suite("ThemePreviewGenerator") +struct ThemePreviewGeneratorTests { + + let colorsOnlyConfig = ThemeConfig( + colors: [ + ThemeToken(name: "surface", style: "surface"), + ThemeToken(name: "primary", style: "primaryColor") + ] + ) + + let fullConfig = ThemeConfig( + colors: [ThemeToken(name: "surface", style: "surface")], + gradients: [ThemeToken(name: "primary", style: "primary")], + shadows: [ThemeToken(name: "card", style: "cardShadow")] + ) + + // MARK: - File generation + + @Test func generate_returnsCorrectFileName() { + let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig) + #expect(file.name == "Theme+Preview.swift") + } + + @Test func generate_includesGeneratedHeader() { + let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig) + #expect(file.content.hasPrefix("// Generated by ThemeKit — do not edit")) + } + + @Test func generate_importsSwiftUIAndThemeKit() { + let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig) + #expect(file.content.contains("import SwiftUI")) + #expect(file.content.contains("import ThemeKit")) + } + + @Test func generate_containsThemePreviewStruct() { + let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig) + #expect(file.content.contains("public struct ThemePreview: View")) + #expect(file.content.contains("public var body: some View")) + } + + @Test func generate_doesNotContainEnvironmentProperties() { + let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig) + #expect(!file.content.contains("@Environment")) + } + + @Test func generate_containsPreviewMacro() { + let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig) + #expect(file.content.contains("#Preview")) + #expect(file.content.contains("ThemePreview()")) + } + + // MARK: - Colors section + + @Test func colorsPresent_includesColorsSection() { + let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig) + #expect(file.content.contains("// MARK: - Colors")) + #expect(file.content.contains("Text(\"Colors\")")) + #expect(file.content.contains("ColorSwatch")) + } + + @Test func colorsPresent_includesAllColorTokens() { + let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig) + #expect(file.content.contains("ColorSwatch(name: \"surface\", style: .surface)")) + #expect(file.content.contains("ColorSwatch(name: \"primary\", style: .primaryColor)")) + } + + @Test func colorsAbsent_excludesColorsSection() { + let config = ThemeConfig(gradients: [ThemeToken(name: "primary", style: "primary")]) + let file = ThemePreviewGenerator().generate(from: config) + #expect(!file.content.contains("// MARK: - Colors")) + #expect(!file.content.contains("ColorSwatch")) + } + + // MARK: - Gradients section + + @Test func gradientsPresent_includesGradientsSection() { + let file = ThemePreviewGenerator().generate(from: fullConfig) + #expect(file.content.contains("// MARK: - Gradients")) + #expect(file.content.contains("Text(\"Gradients\")")) + #expect(file.content.contains("GradientStrip")) + } + + @Test func gradientsPresent_includesAllGradientTokens() { + let file = ThemePreviewGenerator().generate(from: fullConfig) + #expect(file.content.contains("GradientStrip(name: \"primary\", style: .primary)")) + } + + @Test func gradientsAbsent_excludesGradientsSection() { + let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig) + #expect(!file.content.contains("// MARK: - Gradients")) + #expect(!file.content.contains("GradientStrip")) + } + + // MARK: - Shadows section + + @Test func shadowsPresent_includesShadowsSection() { + let file = ThemePreviewGenerator().generate(from: fullConfig) + #expect(file.content.contains("// MARK: - Shadows")) + #expect(file.content.contains("Text(\"Shadows\")")) + #expect(file.content.contains("ShadowCard")) + } + + @Test func shadowsPresent_includesAllShadowTokens() { + let file = ThemePreviewGenerator().generate(from: fullConfig) + #expect(file.content.contains("ShadowCard(name: \"card\", style: .background.cardShadow)")) + } + + @Test func shadowsAbsent_excludesShadowsSection() { + let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig) + #expect(!file.content.contains("// MARK: - Shadows")) + #expect(!file.content.contains("ShadowCard")) + } + + // MARK: - Preview components + + @Test func generate_includesColorSwatchComponent() { + let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig) + #expect(file.content.contains("private struct ColorSwatch: View")) + } + + @Test func generate_includesGradientStripComponent() { + let config = ThemeConfig(gradients: [ThemeToken(name: "primary", style: "primary")]) + let file = ThemePreviewGenerator().generate(from: config) + #expect(file.content.contains("private struct GradientStrip: View")) + } + + @Test func generate_includesShadowCardComponent() { + let config = ThemeConfig(shadows: [ThemeToken(name: "card", style: "card")]) + let file = ThemePreviewGenerator().generate(from: config) + #expect(file.content.contains("private struct ShadowCard: View")) + } + + // MARK: - Layout structure + + @Test func generate_usesScrollView() { + let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig) + #expect(file.content.contains("ScrollView")) + } + + @Test func generate_usesVStackForSections() { + let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig) + #expect(file.content.contains("VStack(alignment: .leading, spacing:")) + } + + @Test func emptyConfig_generatesValidPreviewWithNoSections() { + let config = ThemeConfig() + let file = ThemePreviewGenerator().generate(from: config) + + #expect(file.name == "Theme+Preview.swift") + #expect(file.content.contains("public struct ThemePreview: View")) + #expect(file.content.contains("ScrollView")) + #expect(!file.content.contains("// MARK: - Colors")) + #expect(!file.content.contains("// MARK: - Gradients")) + #expect(!file.content.contains("// MARK: - Shadows")) + } +} diff --git a/theme.schema.json b/theme.schema.json index c191035..03285a4 100644 --- a/theme.schema.json +++ b/theme.schema.json @@ -34,6 +34,10 @@ "outputPath": { "type": "string", "description": "Relative path from the generation root where generated files should be written (e.g., 'UI/Theme'). Defaults to '.' if not specified." + }, + "shouldGeneratePreview": { + "type": "boolean", + "description": "When true, generates a Theme+Preview.swift file containing a SwiftUI view that renders all tokens as a visual palette. Defaults to false if not specified." } }, "required": ["outputPath"],