Skip to content
Merged
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
29 changes: 21 additions & 8 deletions .github/pages/src/components/ConfigSection.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,31 @@
<h2>Configuration</h2>
<div class="config-fields">
{#each properties as prop}
{#if prop.type === 'boolean'}
<div class="config-field config-field-bool">
<div>
<label for="config-{prop.key}">{prop.key}</label>
{#if prop.description}
<p class="config-field-desc">{prop.description}</p>
{/if}
</div>
<label class="toggle">
<input
id="config-{prop.key}"
type="checkbox"
checked={getConfig(prop.key) ?? false}
onchange={(e) => setConfig(prop.key, e.target.checked)}
/>
<span class="toggle-slider"></span>
</label>
</div>
{:else}
<div class="config-field">
<label for="config-{prop.key}">
{prop.key}
{#if prop.required}<span class="required">*</span>{/if}
</label>
{#if prop.type === 'boolean'}
<input
id="config-{prop.key}"
type="checkbox"
checked={getConfig(prop.key) ?? false}
onchange={(e) => setConfig(prop.key, e.target.checked)}
/>
{:else if prop.type === 'number'}
{#if prop.type === 'number'}
<input
id="config-{prop.key}"
type="number"
Expand All @@ -39,6 +51,7 @@
/>
{/if}
</div>
{/if}
{/each}
</div>
</section>
Expand Down
9 changes: 6 additions & 3 deletions .github/pages/src/lib/state.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions .github/pages/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)

</details>

Expand Down
11 changes: 10 additions & 1 deletion Sources/ThemeKitGenerator/ThemeConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand All @@ -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 {
Expand Down
8 changes: 7 additions & 1 deletion Sources/ThemeKitGenerator/ThemeFileGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
191 changes: 191 additions & 0 deletions Sources/ThemeKitGenerator/ThemePreviewGenerator.swift
Original file line number Diff line number Diff line change
@@ -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<Style: ShapeStyle>: 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<Style: ShapeStyle>: 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<Style: ShapeStyle>: 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)
}
}
}
"""
}

}
Loading