Os exemplos usam a API 2.0

Escrito originalmente na 1.3.3 e revisado contra a 2.0. Se você quer só o resumo do que mudou, leia O que mudou no WingedSwift 2.0. Se você já tem um projeto em 1.x para atualizar, o caminho é Migrando do WingedSwift 1.3.3 para o 2.0.

Aqui a gente começa do zero, então nada disso é pré-requisito.

Como desenvolvedor iOS, já me acostumei a trabalhar com Swift no dia a dia. Mas você já pensou em usar Swift para criar sites estáticos? É exatamente isso que o WingedSwift permite fazer - uma biblioteca Swift que gera HTML usando uma DSL (Domain-Specific Language) elegante e type-safe.

Neste artigo, vou mostrar como criar uma landing page moderna e responsiva usando WingedSwift combinado com Tailwind CSS, seguindo a mesma abordagem que usei para criar o site do NFC Forge.

Índice

O que vamos construir

Vamos criar uma landing page completa para um aplicativo, incluindo:

  • Hero section com screenshot do app
  • Seção de features com ícones
  • Design responsivo e moderno
  • Integração com Google Analytics
  • Build automatizado com Tailwind CSS

Por que WingedSwift?

Antes de mergulharmos no código, deixa eu explicar por que desenvolvi WingedSwift e estou trabalhando com ele em meus sites:

  1. Type Safety: Erros de sintaxe são capturados em tempo de compilação
  2. Familiaridade: Se você já conhece Swift, vai se sentir em casa
  3. Composição: Código reutilizável e modular
  4. Performance: Gera HTML estático, super rápido para servir

A muitos e muitos anos atrás em uma galaxia não tão distânte, eu havia desenvolvido algo similar mas com PHP. Na época tinha empresa de desenvolvimento de sites, sistemas web e aplicativos, e isso me ajudou muito a agilizar o trabalho pois conseguia construir diversos templates para sites e sistemas. Com meu foco ficando exclusivamente em mobile resolvi fazer o mesmo com uma linguagem quem me apaixonei que foi o Swift.

Bora codar...

Estrutura do Projeto

Vamos começar criando a estrutura básica do projeto:

meu-site/
├── Sources/
│   └── MeuSite/
│       ├── MeuSite.swift    # Gerador principal
│       ├── Config.swift     # Configurações
│       └── Models.swift     # Modelos de dados
├── assets/
│   ├── images/
│   ├── css/
│   │   └── tailwind.input.css
│   └── js/
│       └── script.js
├── output/                  # Site gerado
├── Package.swift
├── package.json
├── tailwind.config.ts
└── build.sh

Existe um atalho para chegar aqui, aliás: a 2.0 trouxe uma CLI, e winged new MeuSite --tailwind cospe esse esqueleto pronto — descrevo ela neste outro texto. Ainda assim recomendo seguir o passo a passo abaixo: é ele que mostra o que a CLI faz por baixo, e é o que você vai precisar entender quando o site sair do esqueleto.

Passo 1: Configurando o Package.swift

Primeiro, vamos criar o arquivo Package.swift para definir as dependências:

// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "MeuSite",
    platforms: [
        .macOS(.v12)
    ],
    dependencies: [
        .package(url: "https://github.com/micheltlutz/Winged-Swift.git", from: "2.0.0")
    ],
    targets: [
        .executableTarget(
            name: "MeuSite",
            dependencies: [
                .product(name: "WingedSwift", package: "Winged-Swift")
            ]
        ),
    ]
)
Aqui uso swift-tools-version: 6.0 porque projeto novo não tem motivo para nascer velho. O seu pacote não precisa ser 6.0 — o requisito da 2.0 é o compilador ser 6.0+, não o manifesto.

Passo 2: Criando os Modelos de Dados

Vamos criar modelos simples para representar nosso conteúdo. Em vez de usar um CMS externo como Strapi, vamos usar estruturas Swift e opcionalmente carregar dados de um JSON.

// Sources/MeuSite/Models.swift
import Foundation

struct Feature: Codable {
    let id: Int
    let title: String
    let description: String
    let icon: String
    let order: Int
}

struct AppConfig: Codable {
    let name: String
    let description: String
    let shortDescription: String
    let appStoreURL: String
    let screenshot: String
    let logo: String
    let features: [Feature]
}

// Configurações do site
struct Config {
    static let outputDir = "./output"
    static let siteName = "Meu App"
    static let siteDescription = "Descrição do meu aplicativo"
    static let siteURL = "https://meuapp.com"
    static let author = "Seu Nome"
    static let accentColor = "#99cc00"
    static let googleAnalyticsID = "G-XXXXXXXXXX"
}

Passo 3: Carregando Dados de JSON (Opcional)

Se preferir manter o conteúdo em um arquivo JSON, podemos criar um carregador:

// Sources/MeuSite/DataLoader.swift
import Foundation

struct DataLoader {
    static func loadAppConfig(from path: String = "./content.json") -> AppConfig? {
        guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)),
              let config = try? JSONDecoder().decode(AppConfig.self, from: data) else {
            return nil
        }
        return config
    }
    
    static func getDefaultConfig() -> AppConfig {
        return AppConfig(
            name: "Meu App",
            description: "Descrição completa do aplicativo",
            shortDescription: "Uma descrição curta e impactante",
            appStoreURL: "https://apps.apple.com/app/meu-app",
            screenshot: "./assets/images/screenshot.png",
            logo: "./assets/images/logo.png",
            features: [
                Feature(
                    id: 1,
                    title: "Feature 1",
                    description: "Descrição detalhada da primeira feature",
                    icon: "star.svg",
                    order: 1
                ),
                Feature(
                    id: 2,
                    title: "Feature 2",
                    description: "Descrição detalhada da segunda feature",
                    icon: "heart.svg",
                    order: 2
                )
            ]
        )
    }
}

Passo 4: Criando o Gerador Principal

Agora vamos criar o arquivo principal que gera o HTML usando WingedSwift, em alguns momentos vou usar algumas formas diferentes de escrever uma Tag HTML para exemplificar a flexibilidade que o WingedSwift tem:

// Sources/MeuSite/MeuSite.swift
import Foundation
import WingedSwift

@main
struct MeuSite {
    static func main() async throws {
        print("Gerando site...")
        
        // Carregar dados (JSON ou padrão)
        let config = DataLoader.loadAppConfig() ?? DataLoader.getDefaultConfig()
        
        let generator = StaticSiteGenerator(outputDirectory: Config.outputDir)
        let mainPage = createMainPage(config: config)
        
        try generator.generate(
            document: mainPage,
            to: "index.html",
            options: .compact
        )
        
        print("Site gerado com sucesso em: \(Config.outputDir)")
    }
    
    static func createMainPage(config: AppConfig) -> Document {
        return Document(
            lang: "pt-BR",
            head: Head {
                Meta(charset: "UTF-8")
                Meta(name: "viewport", content: "width=device-width, initial-scale=1.0")
                Title(content: "\(config.name) - \(Config.siteDescription)")

                // SEO Meta Tags
                Meta(name: "description", content: config.description)
                Meta(name: "keywords", content: "app, ios, swift")
                Meta(name: "author", content: Config.author)

                // Open Graph — repare no `property:`, e não `name:`: é o que o
                // LinkedIn e o WhatsApp leem para montar o preview do link.
                Meta(property: "og:title", content: config.name)
                Meta(property: "og:description", content: config.description)
                Meta(property: "og:type", content: "website")
                Meta(property: "og:url", content: Config.siteURL)
                Meta(property: "og:image", content: "\(Config.siteURL)\(config.logo.dropFirst(1))")

                // Tailwind CSS
                Link(href: "./assets/css/style.css", rel: "stylesheet")

                // Google Analytics
                Script(attributes: [
                    Attribute.boolean("async"),
                    Attribute(key: "src", value: "https://www.googletagmanager.com/gtag/js?id=\(Config.googleAnalyticsID)")
                ])
                Script(attributes: [], content: """
                    window.dataLayer = window.dataLayer || [];
                    function gtag(){dataLayer.push(arguments);}
                    gtag('js', new Date());
                    gtag('config', '\(Config.googleAnalyticsID)');
                    """)
            },
            body: Body {
                createHeader(config: config)
                createHeroSection(config: config)
                createFeaturesSection(features: config.features)
                createFooter()
            }
            .addClass("bg-black text-white min-h-screen")
        )
    }
    
    static func createHeader(config: AppConfig) -> HTMLTag {
        return Header {
            Div {
                Img(src: config.logo, alt: "\(config.name) Logo")
                    .addClass("w-12 h-12 rounded-xl")
                Nav {
                    A(href: "#features", content: "Features")
                        .addClass("text-white hover:text-accent transition-colors")
                }
                .addClass("ml-auto flex gap-6")
            }
            .addClass("container mx-auto px-6 py-4 flex items-center")
        }
        .addClass("bg-black border-b border-gray-800")
    }
    
    static func createHeroSection(config: AppConfig) -> HTMLTag {
        return Section(children: [
            Div(children: [
                Div(children: [
                    createIPhoneMockup(screenshot: config.screenshot)
                ])
                .addClass("flex justify-center"),
                
                Div(children: [
                    Div(children: [
                        Img(src: config.logo, alt: "\(config.name) Icon")
                            .addClass("w-20 h-20 rounded-2xl shadow-lg"),
                        Div(children: [
                            H1(content: config.name)
                                .addClass("text-5xl font-bold text-white mb-1"),
                            Span(content: "App")
                                .addClass("text-gray-400 text-xl")
                        ])
                        .addClass("ml-6")
                    ])
                    .addClass("flex items-center mb-6"),
                    
                    P(content: config.shortDescription)
                        .addClass("text-gray-300 text-lg leading-relaxed mb-8 max-w-lg"),
                    
                    HTMLTag("a", attributes: [
                        Attribute(key: "href", value: config.appStoreURL),
                        Attribute(key: "target", value: "_blank"),
                        Attribute(key: "rel", value: "noopener noreferrer")
                    ], children: [
                        Img(src: "./assets/images/app-store-badge.svg", alt: "Download on the App Store")
                            .addClass("h-14")
                    ])
                    .addClass("inline-block")
                ])
                .addClass("flex flex-col justify-center lg:pl-12")
            ])
            .addClass("container mx-auto px-6 py-16 lg:py-24 grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-16 items-center")
        ])
        .addClass("bg-black")
    }
    
    static func createIPhoneMockup(screenshot: String) -> HTMLTag {
        return Div {
            Img(src: screenshot, alt: "App Screenshot")
                .addClass("w-full h-full object-cover rounded-[2.5rem]")
        }
        .addClass("w-80 h-[694px] bg-black rounded-[3rem] border-8 border-gray-800 shadow-2xl overflow-hidden")
    }
    
    static func createFeaturesSection(features: [Feature]) -> HTMLTag {
        let sortedFeatures = features.sorted { $0.order < $1.order }

        return Section {
            Div {
                H2(content: "Recursos Principais")
                    .addClass("text-4xl font-bold text-center mb-16 text-white")

                // `map` dentro do builder: na 2.0 isso compila sem ambiguidade,
                // não precisa montar o array antes.
                Div {
                    sortedFeatures.map { createFeatureCard(feature: $0) }
                }
                .addClass("grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8")
            }
            .addClass("container mx-auto px-6 py-16")
        }
        .addClass("bg-black")
    }
    
    static func createFeatureCard(feature: Feature) -> HTMLTag {
        return Div {
            Div {
                Div {
                    Img(src: "./assets/images/icons/\(feature.icon)", alt: feature.title)
                        .addClass("w-12 h-12")
                }
                .addClass("flex justify-center mb-4")

                H3(content: feature.title)
                    .addClass("text-xl font-bold text-white mb-3")

                P(content: feature.description)
                    .addClass("text-gray-300 leading-relaxed flex-grow")
            }
            .addClass("text-center p-8 bg-gray-800 rounded-lg hover:bg-gray-700 transition-colors flex flex-col h-full")
        }
        .addClass("h-full")
    }
    
    static func createFooter() -> HTMLTag {
        return Footer {
            Div {
                P(content: "© 2026 \(Config.author). Todos os direitos reservados.")
                    .addClass("text-gray-400 text-center")
            }
            .addClass("container mx-auto px-6 py-8")
        }
        .addClass("bg-black border-t border-gray-800")
    }
}

Repare que a createHeroSection acima continua na forma com children: [...] enquanto as outras usam a closure. É de propósito: a forma em array não foi descontinuada e as duas convivem no mesmo arquivo. Onde tem if ou map, o builder ganha de longe; onde é só uma sequência fixa de irmãos, o array às vezes lê melhor. Comparo as duas em detalhe aqui.

Passo 5: Configurando Tailwind CSS

Crie o arquivo tailwind.config.ts:

import type { Config } from 'tailwindcss'

const config: Config = {
  content: [
    './Sources/**/*.swift',
    './output/**/*.html',
  ],
  darkMode: 'class',
  theme: {
    extend: {
      colors: {
        primary: '#000000',
        secondary: '#1a1a1a',
        accent: '#99cc00',
        'accent-light': '#b8e633',
        text: '#ffffff',
        'text-secondary': '#a0a0a0',
      },
    },
  },
  plugins: [],
}

export default config

E o arquivo assets/css/tailwind.input.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  body {
    font-family: 'Inter', system-ui, -apple-system, sans-serif;
    -webkit-font-smoothing: antialiased;
  }
}

@layer components {
  .container {
    @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
  }
}

Passo 6: Criando o Script de Build

O script build.sh automatiza todo o processo:

#!/bin/bash

echo "🚀 Building site..."

# Limpar builds anteriores
rm -rf .build output

# Build Swift
echo "Building Swift project..."
swift build
if [ $? -ne 0 ]; then
    echo "❌ Swift build failed!"
    exit 1
fi

# Criar diretório output antes de gerar os arquivos
echo "Creating output directory..."
mkdir -p output/assets/images/icons
mkdir -p output/assets/css
mkdir -p output/assets/js

# Executar gerador (gera os arquivos HTML em output/)
echo "Generating HTML..."
.build/debug/MeuSite
if [ $? -ne 0 ]; then
    echo "❌ Site generation failed!"
    exit 1
fi

# Copiar assets
echo "Copying assets..."
cp -r assets/images/* output/assets/images/ 2>/dev/null || true
cp -r assets/js/* output/assets/js/ 2>/dev/null || true

# Instalar Tailwind CSS se necessário
if [ ! -d "node_modules" ]; then
    echo "Installing Tailwind CSS..."
    npm install
fi

# Compilar Tailwind CSS
echo "Compiling Tailwind CSS..."
# Garantir que o diretório CSS existe
mkdir -p ./output/assets/css
if [ -f "node_modules/.bin/tailwindcss" ]; then
    ./node_modules/.bin/tailwindcss -i ./assets/css/tailwind.input.css -o ./output/assets/css/style.css --minify
else
    npx --yes tailwindcss@latest -i ./assets/css/tailwind.input.css -o ./output/assets/css/style.css --minify
fi

echo "✅ Build complete! Site available in output/"

Passo 7: Criando o package.json

{
  "name": "meu-site",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "build:css": "tailwindcss -i ./assets/css/tailwind.input.css -o ./output/assets/css/style.css --minify"
  },
  "devDependencies": {
    "tailwindcss": "^3.4.18"
  }
}

Passo 8: Arquivo JSON de Conteúdo (Opcional)

Se preferir usar JSON, crie um arquivo content.json:

{
  "name": "Meu App",
  "description": "Descrição completa do aplicativo",
  "shortDescription": "Uma descrição curta e impactante do que o app faz",
  "appStoreURL": "https://apps.apple.com/app/meu-app",
  "screenshot": "./assets/images/screenshot.png",
  "logo": "./assets/images/logo.png",
  "features": [
    {
      "id": 1,
      "title": "Feature 1",
      "description": "Descrição detalhada da primeira feature",
      "icon": "star.svg",
      "order": 1
    },
    {
      "id": 2,
      "title": "Feature 2",
      "description": "Descrição detalhada da segunda feature",
      "icon": "heart.svg",
      "order": 2
    }
  ]
}

Passo 9: O arquivo .gitignore

Para manter o repositório limpo e evitar commitar arquivos desnecessários, crie um arquivo .gitignore na raiz do projeto:

# Swift
.build/
*.xcodeproj
*.xcworkspace
.swiftpm/
Package.resolved

# Build output (site gerado)
# IMPORTANTE: A pasta output/ NÃO deve ser ignorada se você fizer deploy
# direto do repositório (como AWS Amplify). Ela contém o site estático.
# output/  ← Descomente apenas se não precisar commitar o output

# Node.js
node_modules/
package-lock.json
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# macOS
.DS_Store
.AppleDouble
.LSOverride

# IDEs
.vscode/
.idea/
*.swp
*.swo
*~

# Arquivos de conteúdo (opcional)
# Se você usar JSON para conteúdo e não quiser versionar:
# content.json
# content.local.json

**Nota importante sobre o diretório output/**: se você fizer deploy direto do repositório (como no AWS Amplify), vai precisar commitar a pasta output/ com o site gerado. Se o build rodar durante o deploy, pode ignorar essa pasta.

Executando o Build

Agora você pode gerar o site:

# Dar permissão de execução
chmod +x build.sh

# Executar build
./build.sh

# Servir localmente
cd output && python3 -m http.server 8000

Ou, sem depender do Python, com o servidor embutido na CLI da 2.0:

winged serve --output output --no-build

O --no-build é o que faz isso funcionar aqui: ele pula o build da CLI e serve o que o build.sh já gerou.

Acesse http://localhost:8000 para ver o site gerado.

Se der zebra

Quase todo problema na primeira tentativa cai num destes sete. Os erros abaixo são os que a máquina imprime de verdade, não versões parafraseadas.

O CSS sai quase vazio e nenhuma classe funciona

O sintoma é o site aparecer sem estilo nenhum, com um style.css de uns 4 KB. Esses 4 KB são só o preflight do Tailwind — nenhuma utility entrou. Confira o começo da saída do build:

warn - No utility classes were detected in your source files. If this is unexpected, double-check the `content` option in your Tailwind CSS configuration.

São duas causas possíveis, e as duas são de configuração. A primeira é o content do tailwind.config.ts não incluir o HTML gerado:

content: [
  './Sources/**/*.swift',
  './output/**/*.html',   // sem esta linha, as classes do HTML final somem
]

A segunda é ordem no build.sh: o Tailwind varre arquivos que existem no momento em que roda. Se ele rodar antes do gerador Swift, a pasta output/ ainda está vazia e o resultado é o mesmo CSS capenga. Gere o HTML primeiro, compile o CSS depois — é por isso que o build.sh deste artigo tem essa ordem.

Para confirmar que voltou ao normal, procure uma classe que você sabe que existe:

grep -c 'bg-black' output/assets/css/style.css

Uma classe específica some, o resto funciona

Aposto que ela é montada com interpolação:

let cor = "red"
Div().addClass("text-\(cor)-500")   // não aparece no CSS
Div().addClass("text-blue-500")     // aparece

O scanner do Tailwind faz match textual no arquivo. Ele nunca executa seu Swift, então text-\(cor)-500 não é nada que ele reconheça. Use classe literal ou declare as variações na safelist do tailwind.config.ts.

O build quebra com "could not determine executable to run"

npm error could not determine executable to run

Esse vem do fallback do build.sh, o npx --yes tailwindcss@latest. O latest hoje é a v4, e a v4 não traz mais CLI dentro do pacote tailwindcss — ela mudou de casa para @tailwindcss/cli. Por tabela, a v4 também não entende a sintaxe @tailwind base que está no tailwind.input.css deste artigo, que é v3.

A correção é rodar npm install antes, para o build usar o node_modules/.bin/tailwindcss na versão que o package.json pina. Se quiser blindar o fallback, fixe a major nele:

npx --yes tailwindcss@3 -i ./assets/css/tailwind.input.css -o ./output/assets/css/style.css --minify

Mudei uma classe e o navegador não mostra

Antes de culpar o Tailwind, confira se o arquivo mudou mesmo:

ls -l output/assets/css/style.css

Se o horário for recente, é cache do navegador — recarregue com cache desabilitado. Se não for, o build parou antes de chegar no CSS: role a saída do ./build.sh procurando um erro anterior.

bash: .build/debug/MeuSite: No such file or directory

bash: .build/debug/MeuSite: No such file or directory

O swift build passou, mas o binário tem outro nome. O caminho no build.sh precisa bater com o nome do target no Package.swift — se você renomeou um, renomeie o outro. Para descobrir o caminho real sem adivinhar:

ls "$(swift build --show-bin-path)"

O swift build falha na resolução da dependência

Confira a toolchain antes de qualquer outra hipótese:

swift --version

O WingedSwift 2.0 compila em language mode 6, então o compilador precisa ser 6.0 ou mais novo (Xcode 16+). O swift-tools-version do seu pacote pode continuar em 5.9 — o requisito é do compilador, não do manifesto.

O winged build diz que gerou zero páginas

O winged build roda o seu executável passando o diretório de saída como primeiro argumento, e depois conta os HTML que apareceram lá. O gerador deste artigo ignora argumentos e escreve direto em Config.outputDir, então o site é gerado em output/ enquanto a CLI procura em dist/ e não acha nada.

Não é erro, é desencontro de layout. Aqui a CLI serve só como servidor:

./build.sh
winged serve --output output --no-build

O --no-build pula o build da CLI e serve o que o build.sh já gerou. Se quiser usar o winged build de verdade, faça o gerador aceitar o argumento — mostro isso no guia de migração.

Deploy

O diretório output/ contém o site completo pronto para deploy. Você pode:

  • Fazer deploy no AWS Amplify
  • Fazer deploy no Vercel
  • Fazer deploy no GitHub Pages
  • Fazer deploy no Fly.io
  • Fazer deploy em qualquer servidor web estático

Concluindo

No fim dos nove passos você tem um projeto Swift de umas 200 linhas que cospe uma landing page responsiva, com SEO, Open Graph, analytics e CSS minificado — e um output/ que dá para jogar em qualquer hospedagem estática sem servidor de aplicação no meio.

O que eu acho mais interessante nessa abordagem não é a página em si, é o que ela abre depois. Uma seção nova é uma função nova. Um segundo idioma é um for em volta da geração. Uma coleção de posts é um map sobre um array. Você não sai do Swift nem aprende template engine para nada disso — e o compilador continua sendo o primeiro a reclamar quando você erra.

Para projetos menores, ou quando um CMS completo seria mais infraestrutura do que conteúdo, manter tudo no código (ou num JSON ao lado) é simples de um jeito difícil de voltar atrás.

Recursos