diff --git a/.ENV.example b/.ENV.example new file mode 100644 index 0000000..d6bfa3d --- /dev/null +++ b/.ENV.example @@ -0,0 +1,17 @@ +# Copy this file to `.ENV` and fill in your real values before running repo commands. +# +# `just backend` and `just local` use all values below except `AWS_S3_BUCKET`. +# `AWS_S3_BUCKET` is only used by `just site-deploy`. +AWS_REGION=us-east-1 +AWS_S3_BUCKET=your-site-bucket +GENERATED_IMAGES_BUCKET=your-generated-images-bucket +OPENAI_API_KEY=your-openai-api-key +AWS_ACCESS_KEY_ID=your-aws-access-key-id +AWS_SECRET_ACCESS_KEY=your-aws-secret-access-key +OPENAI_IMAGE_MODEL=gpt-image-1.5 +IMAGE_GEN_PREFIX=generated/v2 + +BACKEND_HOST=127.0.0.1 +BACKEND_PORT=8080 +SITE_HOST=127.0.0.1 +SITE_PORT=8000 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 561261c..7f9db91 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,51 +2,112 @@ name: Build and Deploy on: push: - branches: [ master ] - + branches: + - main + +concurrency: + group: deploy-production + cancel-in-progress: true + jobs: - build: - runs-on: macos-latest - steps: - - uses: actions/checkout@v4 - - name: Build - run: swift run -c release bytesized - - uses: actions/upload-artifact@v4 - with: - name: bytesized - path: Output/ - deploy: - needs: build + deploy_backend: + name: Deploy Backend runs-on: ubuntu-latest + permissions: + contents: read + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_REGION: ${{ vars.AWS_REGION }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + GENERATED_IMAGES_BUCKET: ${{ vars.GENERATED_IMAGES_BUCKET }} + IMAGE_GEN_PREFIX: ${{ vars.IMAGE_GEN_PREFIX }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENAI_IMAGE_MODEL: ${{ vars.OPENAI_IMAGE_MODEL }} + RAILWAY_PROJECT_ID: ${{ vars.RAILWAY_PROJECT_ID }} + RAILWAY_ENVIRONMENT_NAME: ${{ vars.RAILWAY_ENVIRONMENT_NAME }} + RAILWAY_RUNTIME_HOST: "0.0.0.0" + RAILWAY_RUNTIME_PORT: "8080" + RAILWAY_SERVICE_NAME: ${{ vars.RAILWAY_SERVICE_NAME }} + RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }} steps: - - uses: actions/download-artifact@v4 - with: - name: bytesized - path: Output/ - # Sync HTML content - - uses: jakejarvis/s3-sync-action@master + - uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 with: - args: --acl public-read --follow-symlinks --exclude '*' --include 'posts/*' --include 'page/*' --include 'index.html' --content-type 'text/html' - env: - AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - SOURCE_DIR: 'Output/' - # Sync resources - - uses: jakejarvis/s3-sync-action@master + node-version: "22" + + - name: Validate Railway configuration + run: | + set -euo pipefail + + : "${AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID is required}" + : "${AWS_REGION:?AWS_REGION is required}" + : "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY is required}" + : "${GENERATED_IMAGES_BUCKET:?GENERATED_IMAGES_BUCKET is required}" + : "${IMAGE_GEN_PREFIX:?IMAGE_GEN_PREFIX is required}" + : "${OPENAI_API_KEY:?OPENAI_API_KEY is required}" + : "${OPENAI_IMAGE_MODEL:?OPENAI_IMAGE_MODEL is required}" + : "${RAILWAY_PROJECT_ID:?RAILWAY_PROJECT_ID is required}" + : "${RAILWAY_ENVIRONMENT_NAME:?RAILWAY_ENVIRONMENT_NAME is required}" + : "${RAILWAY_SERVICE_NAME:?RAILWAY_SERVICE_NAME is required}" + : "${RAILWAY_TOKEN:?RAILWAY_TOKEN is required}" + + - name: Sync Railway backend variables + run: ./Scripts/sync-railway-backend-variables.sh + + - name: Deploy backend to Railway + run: | + set -euo pipefail + + npx -y @railway/cli up Backend \ + --ci \ + --path-as-root \ + --project "${RAILWAY_PROJECT_ID}" \ + --environment "${RAILWAY_ENVIRONMENT_NAME}" \ + --service "${RAILWAY_SERVICE_NAME}" \ + --message "GitHub Actions ${GITHUB_SHA}" + + deploy_site: + name: Deploy Site + runs-on: macos-latest + needs: deploy_backend + env: + AWS_REGION: ${{ vars.AWS_REGION }} + BYTESIZED_CAFE_API_URL: ${{ vars.BYTESIZED_CAFE_API_URL }} + steps: + - uses: actions/checkout@v4 + + - name: Set up just + uses: extractions/setup-just@v3 + + - name: Validate site API URL + run: | + set -euo pipefail + + : "${AWS_REGION:?AWS_REGION is required}" + : "${BYTESIZED_CAFE_API_URL:?BYTESIZED_CAFE_API_URL is required}" + + - name: Set up SwiftWasm + uses: swiftwasm/setup-swiftwasm@v2 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 with: - args: --acl public-read --follow-symlinks --include '*' --exclude 'posts/*' --exclude 'page/*' --exclude 'index.html' + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ vars.AWS_REGION }} + + - name: Install Binaryen + run: brew install binaryen + + - name: Build SwiftWASM app + run: just wasm + + - name: Build site + run: just site-release + + - name: Deploy site env: AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - SOURCE_DIR: 'Output/' - # Invalidate CloudFront cache - - name: Invalidate CloudFront cache - uses: chetan/invalidate-cloudfront-action@v2 - env: - DISTRIBUTION: ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} - PATHS: '/index.html /page/* /feed.rss /css/styles.css' - AWS_REGION: 'us-east-1' - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + run: just site-deploy diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..c1f5e3c --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,22 @@ +name: Validate + +on: + pull_request: + push: + branches: + - main + +jobs: + validate_deployment_config: + name: Validate Deployment Config + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Set up just + uses: extractions/setup-just@v3 + + - name: Validate Docker and workflow YAML + run: just validate-deployment diff --git a/.gitignore b/.gitignore index 14b5600..5c541f4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,15 @@ .DS_Store +.ENV Output/* +/bytesized-cafe-app/ /fonts/* /.swiftpm/* /.publish /.build +**/.build .vscode/settings.json /CLAUDE.md +.railway/ +.swiftpm +/Package.resolved +/BytesizedCafe/Package.resolved diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..95d360e --- /dev/null +++ b/.swift-format @@ -0,0 +1,5 @@ +{ + "indentation": { + "spaces": 4 + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ac0bc8e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,60 @@ +# AGENTS.md + +Instructions for coding agents working on this repo. +## General +- When adding new dependencies, check Github to make sure that you’re adding them at the latest release. +- When naming files/targets, prefer non-project namespaced names (eg Server vs BytesizedServer) +## Validating Changes +- Run the swift-format skill +- Run `./Scripts/validate-deployment-config.sh` when changing `Backend/Dockerfile`, `.github/workflows`, or deployment-related scripts +- Keep `SPEC.md` up-to-date when making changes. +- You don't need to run swift-format and swift-test to validate changes to markdown files. + +## Build & Run Commands +- List available recipes: `just help` +- Build the WebAssembly app: `just wasm` +- Build site: `just site` +- Build with release config: `just site-release` +- Build site against a configured local cafe API: `just site-local` +- Run local stack: `just local` +- Run backend only: `just backend` +- Validate deployment config: `just validate-deployment` +- Deploy site to S3: `just site-deploy` + +`just` loads environment variables from `.ENV` automatically. + +## Project Structure +- `Sources/bytesized/`: Swift source files +- `Content/posts/`: Markdown blog posts +- `Resources/`: Static assets (CSS, fonts, images) +- `Output/`: Generated site (not checked in) + +## Content Writing +- Use Markdown for all content in the Content/posts directory +- Include proper metadata with title, date, and path + +## Code Style Guidelines +- Swift 6.2+ codebase using the Publish framework +- Use descriptive variable/function names in camelCase +- Consistent 4-space indentation +- Prefer `Struct` to `Enum` for general data structures +- Group extensions with the type they extend +- Use Swift's strong type system +- Organize imports alphabetically: Foundation first, then third-party +- Keep functions small and focused on a single responsibility +- Use Swift's error handling with do/try/catch +- Follow CommonMark for Markdown content +- Use the `swift-concurrency` skill for Swift concurrency guidance. +- Always mark @Observable classes with @MainActor. +- Assume strict Swift concurrency rules are being applied. +- Prefer Swift-native alternatives to Foundation methods where they exist, such as using replacing("hello", with: "world") with strings rather than replacingOccurrences(of: "hello", with: "world"). +- Prefer modern Foundation API, for example URL.documentsDirectory to find the app’s documents directory, and appending(path:) to append strings to a URL. +- Never use C-style number formatting such as Text(String(format: "%.2f", abs(myNumber))); always use Text(abs(change), format: .number.precision(.fractionLength(2))) instead. +- Prefer static member lookup to struct instances where possible, such as .circle rather than Circle(), and .borderedProminent rather than BorderedProminentButtonStyle(). +- Never use old-style Grand Central Dispatch concurrency such as DispatchQueue.main.async(). If behavior like this is needed, always use modern Swift concurrency. +- Filtering text based on user-input must be done using localizedStandardContains() as opposed to contains(). +- Avoid force unwraps and force try unless it is unrecoverable. + +## Tools +- Prefer `ast-grep` for syntax-aware searches; only use `rg` for plain-text matching when needed. +- Use the `gh` CLI for GitHub operations when available (e.g., creating repos and pushing). diff --git a/Backend/.dockerignore b/Backend/.dockerignore new file mode 100644 index 0000000..8c2660f --- /dev/null +++ b/Backend/.dockerignore @@ -0,0 +1,3 @@ +.build +.swiftpm +.DS_Store diff --git a/Backend/Dockerfile b/Backend/Dockerfile new file mode 100644 index 0000000..a6c145d --- /dev/null +++ b/Backend/Dockerfile @@ -0,0 +1,25 @@ +FROM swift:6.2.4-bookworm AS build + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY Package.swift Package.resolved ./ +COPY Sources ./Sources + +RUN swift build -c release --product Server + +FROM swift:6.2.4-bookworm-slim AS runtime + +WORKDIR /app + +COPY --from=build /app/.build/release/Server /usr/local/bin/Server + +ENV HOST=0.0.0.0 +ENV PORT=8080 + +EXPOSE 8080 + +CMD ["Server"] diff --git a/Backend/Package.resolved b/Backend/Package.resolved new file mode 100644 index 0000000..c010819 --- /dev/null +++ b/Backend/Package.resolved @@ -0,0 +1,267 @@ +{ + "originHash" : "b3d810e30df8bf043d4c6f54dd2f4d2e7e1b145780a5a8b4729e2cdeff65047b", + "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "3b57e00556515b126ad74455de1e6fa456856391", + "version" : "1.33.0" + } + }, + { + "identity" : "aws-crt-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/awslabs/aws-crt-swift", + "state" : { + "revision" : "d754d289d594adc240f55c90eab212bac818509c", + "version" : "0.58.1" + } + }, + { + "identity" : "aws-sdk-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/awslabs/aws-sdk-swift.git", + "state" : { + "revision" : "282c6f08f4421c9ca5179bd2b34bc69615695b48", + "version" : "1.6.80" + } + }, + { + "identity" : "hummingbird", + "kind" : "remoteSourceControl", + "location" : "https://github.com/hummingbird-project/hummingbird.git", + "state" : { + "revision" : "d1ce7bbd2f1b17f22031ca4c0daeb39eff07a92e", + "version" : "2.21.1" + } + }, + { + "identity" : "smithy-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/smithy-lang/smithy-swift", + "state" : { + "revision" : "470017211ad3bfe78f2649433cf0c9449d5607c5", + "version" : "0.193.0" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", + "version" : "1.7.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272", + "version" : "1.1.3" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "24ccdeeeed4dfaae7955fcac9dbf5489ed4f1a25", + "version" : "1.18.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-container-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-container-plugin", + "state" : { + "revision" : "d9a516ac68eff1667df55a997a9ccec2fb8406ce", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095", + "version" : "4.2.0" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "e109d8b5308d0e05201d9a1dd1c475446a946a11", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "76d7627bd88b47bf5a0f8497dd244885960dde0b", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523", + "version" : "1.10.1" + } + }, + { + "identity" : "swift-metrics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-metrics.git", + "state" : { + "revision" : "f17c111cec972c2a4922cef38cf64f76f7e87886", + "version" : "2.8.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "e932d3c4d8f77433c8f7093b5ebcbf91463948a0", + "version" : "2.95.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "3df009d563dc9f21a5c85b33d8c2e34d2e4f8c3b", + "version" : "1.32.1" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "b6571f3db40799df5a7fc0e92c399aa71c883edd", + "version" : "1.40.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "173cc69a058623525a58ae6710e2f5727c663793", + "version" : "2.36.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "60c3e187154421171721c1a38e800b390680fb5d", + "version" : "1.26.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "89888196dd79c61c50bca9a103d8114f32e1e598", + "version" : "2.10.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + } + ], + "version" : 3 +} diff --git a/Backend/Package.swift b/Backend/Package.swift new file mode 100644 index 0000000..179ff58 --- /dev/null +++ b/Backend/Package.swift @@ -0,0 +1,44 @@ +// swift-tools-version: 6.2 + +import PackageDescription + +let package = Package( + name: "BytesizedCafeBackend", + platforms: [ + .macOS(.v15) + ], + products: [ + .library(name: "Core", targets: ["Core"]), + .executable(name: "Server", targets: ["Server"]), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-container-plugin", from: "1.3.0"), + .package(url: "https://github.com/apple/swift-configuration.git", from: "1.2.0"), + .package(url: "https://github.com/apple/swift-http-types.git", from: "1.5.1"), + .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.33.0"), + .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.21.1"), + .package(url: "https://github.com/awslabs/aws-sdk-swift.git", from: "1.6.80"), + ], + targets: [ + .target( + name: "Core", + dependencies: [ + .product(name: "Configuration", package: "swift-configuration") + ] + ), + .executableTarget( + name: "Server", + dependencies: [ + "Core", + .product(name: "Hummingbird", package: "hummingbird"), + .product(name: "HTTPTypes", package: "swift-http-types"), + .product(name: "AsyncHTTPClient", package: "async-http-client"), + .product(name: "AWSS3", package: "aws-sdk-swift"), + ] + ), + .testTarget( + name: "CoreTests", + dependencies: ["Core"] + ), + ] +) diff --git a/Backend/Sources/Core/CoreError.swift b/Backend/Sources/Core/CoreError.swift new file mode 100644 index 0000000..7bf22e6 --- /dev/null +++ b/Backend/Sources/Core/CoreError.swift @@ -0,0 +1,6 @@ +public enum CoreError: Error, Equatable { + case invalidPagePath + case invalidPort(String) + case missingEnvironmentValue(String) + case terminal(code: String, message: String) +} diff --git a/Backend/Sources/Core/Environment.swift b/Backend/Sources/Core/Environment.swift new file mode 100644 index 0000000..cd8dc7c --- /dev/null +++ b/Backend/Sources/Core/Environment.swift @@ -0,0 +1,124 @@ +import Configuration +import Foundation + +public struct Environment: Sendable, Equatable { + public let awsRegion: String + public let generatedImagesBucket: String + public let generatedImagesPrefix: String + public let hostname: String + public let openAIAPIKey: String + public let openAIModel: String + public let port: Int + + public var publicBaseURL: String { + "https://\(generatedImagesBucket).s3.\(awsRegion).amazonaws.com" + } + + enum Keys: String { + case awsRegion = "AWS_REGION" + case backendHost = "BACKEND_HOST" + case backendPort = "BACKEND_PORT" + case generatedImagesBucket = "GENERATED_IMAGES_BUCKET" + case host = "HOST" + case imageGenPrefix = "IMAGE_GEN_PREFIX" + case openAiKey = "OPENAI_API_KEY" + case openAiModel = "OPENAI_IMAGE_MODEL" + case port = "PORT" + } + + public static func load(from values: [String: String] = ProcessInfo.processInfo.environment) + throws -> Self + { + let configuration = ConfigReader( + provider: EnvironmentVariablesProvider( + environmentVariables: values, + secretsSpecifier: .specific([Keys.openAiKey.rawValue]) + ) + ) + + return try Self.load(from: configuration) + } + + private static func load(from configuration: ConfigReader) throws -> Self { + Self( + awsRegion: try Self.requiredValue( + for: .awsRegion, + in: configuration + ), + generatedImagesBucket: try Self.requiredValue( + for: .generatedImagesBucket, + in: configuration + ), + generatedImagesPrefix: try Self.generatedImagesPrefix(from: configuration), + hostname: try Self.hostname(from: configuration), + openAIAPIKey: try Self.requiredValue( + for: .openAiKey, + in: configuration, + isSecret: true + ), + openAIModel: try Self.requiredValue( + for: .openAiModel, + in: configuration + ), + port: try Self.port(from: configuration) + ) + } + + private static func requiredValue( + for key: Keys, + aliases: [Keys] = [], + in configuration: ConfigReader, + isSecret: Bool = false + ) throws -> String { + for candidateKey in [key] + aliases { + guard + let value = configuration.string( + forKey: ConfigKey(candidateKey.rawValue), + isSecret: isSecret + ), + !value.isEmpty + else { + continue + } + + return value + } + + throw CoreError.missingEnvironmentValue( + ([key] + aliases) + .map(\.rawValue) + .joined(separator: " or ") + ) + } + + private static func generatedImagesPrefix(from configuration: ConfigReader) throws -> String { + let normalizedPrefix = try requiredValue(for: .imageGenPrefix, in: configuration) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard !normalizedPrefix.isEmpty else { + throw CoreError.missingEnvironmentValue(Keys.imageGenPrefix.rawValue) + } + + return normalizedPrefix + } + + private static func hostname(from configuration: ConfigReader) throws -> String { + try requiredValue( + for: .host, + aliases: [.backendHost], + in: configuration + ) + } + + private static func port(from configuration: ConfigReader) throws -> Int { + let portValue = try requiredValue( + for: .port, + aliases: [.backendPort], + in: configuration + ) + guard let port = Int(portValue) else { + throw CoreError.invalidPort(portValue) + } + + return port + } +} diff --git a/Backend/Sources/Core/KeyFactory.swift b/Backend/Sources/Core/KeyFactory.swift new file mode 100644 index 0000000..d56fca6 --- /dev/null +++ b/Backend/Sources/Core/KeyFactory.swift @@ -0,0 +1,116 @@ +import Foundation + +public struct KeyFactory: Sendable { + public init() {} + + private let utcTimeZone = TimeZone(secondsFromGMT: 0) ?? .current + + private var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = utcTimeZone + return calendar + } + + private var dateStyle: Date.VerbatimFormatStyle { + return Date.VerbatimFormatStyle( + format: "\(year: .defaultDigits)/\(month: .twoDigits)/\(day: .twoDigits)", + locale: Locale(identifier: "en_US_POSIX"), + timeZone: utcTimeZone, + calendar: utcCalendar + ) + } + + public func publicURL(baseURL: String, key: String) -> String { + "\(baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")))/\(key)" + } + + public func generatedImageKey( + prefix: String, + date: Date = .now, + countryName: String? = nil + ) -> String { + let countrySuffix = countryKeySuffix(countryName: countryName).map { "-\($0)" } ?? "" + return + "\(generatedImagePrefix(prefix: prefix, for: date))\(UUID().uuidString.lowercased())\(countrySuffix).png" + } + + public func pageImageKey( + prefix: String, + context: PageContext, + countryName: String? = nil + ) -> String { + let normalizedPagePath = normalizedPagePathComponents(for: context.pagePath) + .joined(separator: "/") + let countryComponent = countryKeySuffix(countryName: countryName) ?? "anywhere" + + return + "\(trimmedPrefix(prefix))/page-cache/\(context.pageType.rawValue)/\(normalizedPagePath)-\(countryComponent).png" + } + + public func generatedImagePrefix(prefix: String, for date: Date) -> String { + return "\(trimmedPrefix(prefix))/\(date.formatted(dateStyle))/" + } + + public func countryKeySuffix(countryName: String?) -> String? { + guard + let countryName = countryName?.trimmingCharacters(in: .whitespacesAndNewlines), + !countryName.isEmpty + else { + return nil + } + + let normalizedCountryName = + countryName + .folding( + options: [.caseInsensitive, .diacriticInsensitive], + locale: .init(identifier: "en_US_POSIX") + ) + .lowercased() + let parts = normalizedCountryName.split( + whereSeparator: { character in + character.unicodeScalars.allSatisfy { scalar in + !CharacterSet.alphanumerics.contains(scalar) + } + } + ) + let suffix = parts.joined(separator: "-") + + return suffix.isEmpty ? nil : suffix + } + + private func trimmedPrefix(_ prefix: String) -> String { + prefix.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + + private func normalizedPagePathComponents(for pagePath: String) -> [String] { + let trimmedPagePath = pagePath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + + guard !trimmedPagePath.isEmpty else { + return ["root"] + } + + return trimmedPagePath.split(separator: "/").map { component in + keyPathComponent(from: String(component)) + } + } + + private func keyPathComponent(from value: String) -> String { + let normalizedValue = + value + .folding( + options: [.caseInsensitive, .diacriticInsensitive], + locale: .init(identifier: "en_US_POSIX") + ) + .lowercased() + let parts = normalizedValue.split( + whereSeparator: { character in + character.unicodeScalars.allSatisfy { scalar in + !CharacterSet.alphanumerics.contains(scalar) + } + } + ) + let keyPathComponent = parts.joined(separator: "-") + + return keyPathComponent.isEmpty ? "page" : keyPathComponent + } +} diff --git a/Backend/Sources/Core/Models/GenerateRequest.swift b/Backend/Sources/Core/Models/GenerateRequest.swift new file mode 100644 index 0000000..fb8803f --- /dev/null +++ b/Backend/Sources/Core/Models/GenerateRequest.swift @@ -0,0 +1,10 @@ +import Foundation + +public struct GenerateRequest: Codable, Equatable { + public let context: PageContext + + public init(context: PageContext) { + self.context = context + } +} + diff --git a/Backend/Sources/Core/Models/GenerateResponse.swift b/Backend/Sources/Core/Models/GenerateResponse.swift new file mode 100644 index 0000000..9d476aa --- /dev/null +++ b/Backend/Sources/Core/Models/GenerateResponse.swift @@ -0,0 +1,9 @@ +import Foundation + +public struct GenerateResponse: Codable, Equatable { + public let url: String + + public init(url: String) { + self.url = url + } +} diff --git a/Backend/Sources/Core/Models/PageContext.swift b/Backend/Sources/Core/Models/PageContext.swift new file mode 100644 index 0000000..5e50f2b --- /dev/null +++ b/Backend/Sources/Core/Models/PageContext.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct PageContext: Codable, Equatable { + public let pagePath: String + public let pageType: PageType + + public init(pagePath: String, pageType: PageType) { + self.pagePath = pagePath + self.pageType = pageType + } +} diff --git a/Backend/Sources/Core/Models/PageType.swift b/Backend/Sources/Core/Models/PageType.swift new file mode 100644 index 0000000..8d0edda --- /dev/null +++ b/Backend/Sources/Core/Models/PageType.swift @@ -0,0 +1,7 @@ +import Foundation + +public enum PageType: String, Codable { + case archive + case article + case index +} diff --git a/Backend/Sources/Core/PromptBuilder.swift b/Backend/Sources/Core/PromptBuilder.swift new file mode 100644 index 0000000..9b63d08 --- /dev/null +++ b/Backend/Sources/Core/PromptBuilder.swift @@ -0,0 +1,14 @@ +import Foundation + +public struct PromptBuilder { + public static func prompt(countryName: String?) -> String { + let localized: String + if let countryName { + localized = "in the country of \(countryName)" + } else { + localized = "anywhere in the world" + } + return + "random food item popular \(localized), in the style of Overcooked, transparent background" + } +} diff --git a/Backend/Sources/Server/CountryLookupClient.swift b/Backend/Sources/Server/CountryLookupClient.swift new file mode 100644 index 0000000..029d041 --- /dev/null +++ b/Backend/Sources/Server/CountryLookupClient.swift @@ -0,0 +1,44 @@ +import AsyncHTTPClient +import Foundation + +struct CountryLookupClient { + private let httpClient: HTTPClient + private let jsonDecoder = JSONDecoder() + private let baseURL = URL(string: "https://api.country.is")! + + init( + httpClient: HTTPClient = .shared + ) { + self.httpClient = httpClient + } + + func countryName(for ipAddress: String) async throws -> String? { + let endpoint = baseURL.appending(path: ipAddress) + let request = HTTPClientRequest(url: endpoint.absoluteString) + let response = try await httpClient.execute(request, timeout: .seconds(30)) + let body = try await response.body.collect(upTo: 1024 * 1024) + let data = Data(body.readableBytesView) + return try countryName(from: data, statusCode: Int(response.status.code)) + } + + func countryName(from data: Data, statusCode: Int) throws -> String? { + switch statusCode { + case 200..<300: + let lookup = try jsonDecoder.decode(CountryLookupResponse.self, from: data) + let locale = Locale(identifier: "en_US_POSIX") + return locale.localizedString(forRegionCode: lookup.country) + case 400...404: + return nil + default: + throw ServerError.httpFailure( + service: .countryLookup, + statusCode: statusCode, + message: String(decoding: data, as: UTF8.self) + ) + } + } +} + +private struct CountryLookupResponse: Decodable { + let country: String +} diff --git a/Backend/Sources/Server/ImageGenService.swift b/Backend/Sources/Server/ImageGenService.swift new file mode 100644 index 0000000..dfe103d --- /dev/null +++ b/Backend/Sources/Server/ImageGenService.swift @@ -0,0 +1,81 @@ +import Core +import Foundation + +struct ImageGenService: Sendable { + private let dailyGenerationLimit = 15 + + private let environment: Environment + private let keyFactory: KeyFactory + private let s3ImageStore: S3ImageStore + private let openAiClient: OpenAiClient + + init( + environment: Environment, + keyFactory: KeyFactory, + s3ImageStore: S3ImageStore, + openAiClient: OpenAiClient, + ) { + self.environment = environment + self.keyFactory = keyFactory + self.s3ImageStore = s3ImageStore + self.openAiClient = openAiClient + } + + public func handle( + request: GenerateRequest, + countryName: String? + ) async throws + -> GenerateResponse + { + guard !request.context.pagePath.isEmpty else { + throw CoreError.invalidPagePath + } + + let currentDate = Date() + let countrySuffix = keyFactory.countryKeySuffix(countryName: countryName) + let pageImageKey = keyFactory.pageImageKey( + prefix: environment.generatedImagesPrefix, + context: request.context, + countryName: countryName + ) + let pageImageURL = keyFactory.publicURL( + baseURL: environment.publicBaseURL, + key: pageImageKey + ) + + if try await s3ImageStore.imageExists(key: pageImageKey) { + return GenerateResponse(url: pageImageURL) + } + + let datePrefix = keyFactory.generatedImagePrefix( + prefix: environment.generatedImagesPrefix, + for: currentDate + ) + let generatedImageCount = try await s3ImageStore.countGeneratedImages(prefix: datePrefix) + + if generatedImageCount < dailyGenerationLimit { + let prompt = PromptBuilder.prompt(countryName: countryName) + let imageKey = keyFactory.generatedImageKey( + prefix: environment.generatedImagesPrefix, + date: currentDate, + countryName: countryName + ) + let imageBytes = try await openAiClient.generateImage(prompt: prompt) + try await s3ImageStore.uploadImage(key: imageKey, bytes: imageBytes) + try await s3ImageStore.uploadImage(key: pageImageKey, bytes: imageBytes) + return GenerateResponse(url: pageImageURL) + } else { + guard + let imageKey = try await s3ImageStore.randomGeneratedImageKey( + datePrefix: datePrefix, + countrySuffix: countrySuffix + ) + else { + throw ServerError.noValidImageFallback + } + + try await s3ImageStore.copyImage(from: imageKey, to: pageImageKey) + return GenerateResponse(url: pageImageURL) + } + } +} diff --git a/Backend/Sources/Server/Model/ImageGenRequest.swift b/Backend/Sources/Server/Model/ImageGenRequest.swift new file mode 100644 index 0000000..3ee3e3a --- /dev/null +++ b/Backend/Sources/Server/Model/ImageGenRequest.swift @@ -0,0 +1,17 @@ +struct ImageGenRequest: Encodable { + let background: String + let model: String + let outputFormat: String + let prompt: String + let quality: String + let size: String + + enum CodingKeys: String, CodingKey { + case background + case model + case outputFormat = "output_format" + case prompt + case quality + case size + } +} diff --git a/Backend/Sources/Server/Model/ImageGenResponse.swift b/Backend/Sources/Server/Model/ImageGenResponse.swift new file mode 100644 index 0000000..3dd6735 --- /dev/null +++ b/Backend/Sources/Server/Model/ImageGenResponse.swift @@ -0,0 +1,11 @@ +struct ImageGenResponse: Decodable { + let data: [ImageData] + + struct ImageData: Decodable { + let base64JSON: String? + + enum CodingKeys: String, CodingKey { + case base64JSON = "b64_json" + } + } +} diff --git a/Backend/Sources/Server/Model/ServerError.swift b/Backend/Sources/Server/Model/ServerError.swift new file mode 100644 index 0000000..e97da32 --- /dev/null +++ b/Backend/Sources/Server/Model/ServerError.swift @@ -0,0 +1,9 @@ +enum ServerError: Error, Sendable, Equatable { + case httpFailure(service: Service, statusCode: Int, message: String) + case noValidImageFallback + + enum Service: String, Sendable { + case countryLookup + case openAIImage + } +} diff --git a/Backend/Sources/Server/OpenAiClient.swift b/Backend/Sources/Server/OpenAiClient.swift new file mode 100644 index 0000000..e983528 --- /dev/null +++ b/Backend/Sources/Server/OpenAiClient.swift @@ -0,0 +1,84 @@ +import AsyncHTTPClient +import Core +import Foundation +import NIOCore + +struct OpenAiClient { + private let generationTimeout: TimeAmount = .seconds(120) + private let environment: Environment + private let httpClient: HTTPClient + private let jsonEncoder = JSONEncoder() + private let jsonDecoder = JSONDecoder() + private let generationsURL = "https://api.openai.com/v1/images/generations" + + init( + environment: Environment, + httpClient: HTTPClient = .shared + ) { + self.environment = environment + self.httpClient = httpClient + } + + func generateImage(prompt: String) async throws -> Data { + let payload = ImageGenRequest( + background: "transparent", + model: environment.openAIModel, + outputFormat: "png", + prompt: prompt, + quality: "high", + size: "1024x1024" + ) + + let requestBody = try jsonEncoder.encode(payload) + var request = HTTPClientRequest(url: generationsURL) + request.method = .POST + request.headers.add(name: "Authorization", value: "Bearer \(environment.openAIAPIKey)") + request.headers.add(name: "Content-Type", value: "application/json") + request.body = .bytes(ByteBuffer(bytes: requestBody)) + let response = try await httpClient.execute(request, timeout: generationTimeout) + let body = try await response.body.collect(upTo: 10 * 1024 * 1024) + let data = Data(body.readableBytesView) + + return try imageData(from: data, statusCode: Int(response.status.code)) + } + + func imageData(from data: Data, statusCode: Int) throws -> Data { + switch statusCode { + case 200..<300: + let decoded = try jsonDecoder.decode(ImageGenResponse.self, from: data) + guard + let encodedImage = decoded.data.first?.base64JSON, + let imageBytes = Data(base64Encoded: encodedImage) + else { + throw CoreError.terminal( + code: "invalid_openai_response", + message: "The image API did not return PNG bytes." + ) + } + + return imageBytes + case 400..<500 where statusCode != 429: + let apiError = try? jsonDecoder.decode(OpenAIAPIErrorResponse.self, from: data) + let message = apiError?.error.message ?? "The image request was rejected." + + throw CoreError.terminal( + code: "openai_request_failed", + message: message + ) + default: + throw ServerError.httpFailure( + service: .openAIImage, + statusCode: statusCode, + message: String(decoding: data, as: UTF8.self) + ) + } + } +} + +private struct OpenAIAPIErrorResponse: Decodable { + let error: OpenAIAPIError +} + +private struct OpenAIAPIError: Decodable { + let message: String +} diff --git a/Backend/Sources/Server/Request+ClientIPAddress.swift b/Backend/Sources/Server/Request+ClientIPAddress.swift new file mode 100644 index 0000000..50966d4 --- /dev/null +++ b/Backend/Sources/Server/Request+ClientIPAddress.swift @@ -0,0 +1,66 @@ +import Foundation +import HTTPTypes +import Hummingbird + +extension Request { + func clientIPAddress() -> String? { + ClientIPAddressResolver.resolve( + xForwardedFor: headers[.xForwardedFor], + xRealIP: headers[.xRealIP] + ) + } +} + +struct ClientIPAddressResolver { + static func resolve(xForwardedFor: String?, xRealIP: String?) -> String? { + // Railway exposes the originating client address via X-Real-IP. + if let clientIPAddress = normalizedIPAddress(from: xRealIP) { + return clientIPAddress + } + + if let clientIPAddress = forwardedClientIPAddress(from: xForwardedFor) { + return clientIPAddress + } + + return nil + } + + private static func forwardedClientIPAddress(from xForwardedFor: String?) -> String? { + let forwardedIPs = normalizedIPAddresses(from: xForwardedFor) + guard !forwardedIPs.isEmpty else { + return nil + } + + return forwardedIPs[0] + } + + private static func normalizedIPAddresses(from rawValue: String?) -> [String] { + guard let rawValue else { + return [] + } + + return + rawValue + .split(separator: ",") + .compactMap { candidate in + normalizedIPAddress(from: String(candidate)) + } + } + + private static func normalizedIPAddress(from rawValue: String?) -> String? { + let normalizedValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines) + guard + let normalizedValue, + !normalizedValue.isEmpty + else { + return nil + } + + return normalizedValue + } +} + +extension HTTPField.Name { + fileprivate static let xForwardedFor = Self("X-Forwarded-For")! + fileprivate static let xRealIP = Self("X-Real-IP")! +} diff --git a/Backend/Sources/Server/S3ImageStore.swift b/Backend/Sources/Server/S3ImageStore.swift new file mode 100644 index 0000000..725f3b6 --- /dev/null +++ b/Backend/Sources/Server/S3ImageStore.swift @@ -0,0 +1,112 @@ +import AWSS3 +import Foundation +import Smithy + +struct S3ImageStore { + private let bucketName: String + private let client: S3Client + + init(bucketName: String) async throws { + self.bucketName = bucketName + self.client = try await S3Client() + } + + func uploadImage(key: String, bytes: Data) async throws { + let input = PutObjectInput( + body: .data(bytes), + bucket: bucketName, + cacheControl: "public, max-age=31536000, immutable", + contentLength: bytes.count, + contentType: "image/png", + key: key + ) + + _ = try await client.putObject(input: input) + } + + func imageExists(key: String) async throws -> Bool { + do { + _ = try await client.headObject( + input: HeadObjectInput( + bucket: bucketName, + key: key + ) + ) + return true + } catch is AWSS3.NotFound { + return false + } + } + + func copyImage(from sourceKey: String, to destinationKey: String) async throws { + let input = CopyObjectInput( + bucket: bucketName, + copySource: "\(bucketName)/\(sourceKey)", + key: destinationKey + ) + + _ = try await client.copyObject(input: input) + } + + func randomGeneratedImageKey( + datePrefix: String, + countrySuffix: String?, + ) async throws -> String? { + var randomNumberGenerator = SystemRandomNumberGenerator() + var selectedPreferredKey: String? + var seenPreferredKeyCount = 0 + var selectedKey: String? + var seenKeyCount = 0 + + for try await response in client.listObjectsV2Paginated( + input: ListObjectsV2Input( + bucket: bucketName, + prefix: datePrefix + ) + ) { + for object in response.contents ?? [] { + guard let key = object.key, key.hasSuffix(".png") else { + continue + } + + seenKeyCount += 1 + if Int.random(in: 0.. Int { + var count = 0 + + for try await response in client.listObjectsV2Paginated( + input: ListObjectsV2Input( + bucket: bucketName, + prefix: prefix + ) + ) { + count += + response.contents?.reduce(into: 0) { partialResult, object in + if object.key?.hasSuffix(".png") == true { + partialResult += 1 + } + } ?? 0 + } + + return count + } +} diff --git a/Backend/Sources/Server/Server.swift b/Backend/Sources/Server/Server.swift new file mode 100644 index 0000000..cfd5c6a --- /dev/null +++ b/Backend/Sources/Server/Server.swift @@ -0,0 +1,65 @@ +import Core +import Hummingbird + +extension GenerateResponse: ResponseEncodable {} + +@main +struct Server { + static func main() async throws { + let environment = try Environment.load() + let imageStore = try await S3ImageStore(bucketName: environment.generatedImagesBucket) + let countryLookupClient = CountryLookupClient() + let openAiClient = OpenAiClient(environment: environment) + let keyFactory = KeyFactory() + let imageGenService = ImageGenService( + environment: environment, + keyFactory: keyFactory, + s3ImageStore: imageStore, + openAiClient: openAiClient + ) + + let router = Router(context: BasicRequestContext.self) + router.addMiddleware { + CORSMiddleware( + allowOrigin: .originBased, + allowHeaders: [.contentType, .origin], + allowMethods: [.get, .post, .options] + ) + } + router.addMiddleware { + LogRequestsMiddleware(.info) + } + + router.get("/health") { _, _ -> HTTPResponse.Status in + .ok + } + + router.post("/api/cafe/generate") { + request, + context -> GenerateResponse in + let generateRequest = try await request.decode( + as: GenerateRequest.self, + context: context + ) + let countryName: String? + if let clientIPAddress = request.clientIPAddress() { + countryName = try await countryLookupClient.countryName( + for: clientIPAddress + ) + } else { + countryName = nil + } + return try await imageGenService.handle( + request: generateRequest, countryName: countryName) + } + + let app = Application( + router: router, + configuration: .init( + address: .hostname(environment.hostname, port: environment.port) + ) + ) + + try await app.runService() + } +} diff --git a/Backend/Tests/CoreTests/EnvironmentTests.swift b/Backend/Tests/CoreTests/EnvironmentTests.swift new file mode 100644 index 0000000..6b72669 --- /dev/null +++ b/Backend/Tests/CoreTests/EnvironmentTests.swift @@ -0,0 +1,75 @@ +import Testing + +@testable import Core + +struct EnvironmentTests { + @Test func loadsCanonicalRuntimeVariables() throws { + let environment = try Environment.load( + from: [ + "AWS_REGION": "us-east-1", + "GENERATED_IMAGES_BUCKET": "bytesized-generated-images", + "HOST": "127.0.0.1", + "IMAGE_GEN_PREFIX": "/generated/v2/", + "OPENAI_API_KEY": "secret", + "OPENAI_IMAGE_MODEL": "gpt-image-1.5", + "PORT": "8080", + ] + ) + + #expect(environment.awsRegion == "us-east-1") + #expect(environment.generatedImagesBucket == "bytesized-generated-images") + #expect(environment.generatedImagesPrefix == "generated/v2") + #expect(environment.hostname == "127.0.0.1") + #expect(environment.openAIAPIKey == "secret") + #expect(environment.openAIModel == "gpt-image-1.5") + #expect(environment.port == 8080) + } + + @Test func loadsLocalBackendHostAliases() throws { + let environment = try Environment.load( + from: [ + "AWS_REGION": "us-east-1", + "BACKEND_HOST": "127.0.0.1", + "BACKEND_PORT": "9000", + "GENERATED_IMAGES_BUCKET": "bytesized-generated-images", + "IMAGE_GEN_PREFIX": "generated/v2", + "OPENAI_API_KEY": "secret", + "OPENAI_IMAGE_MODEL": "gpt-image-1.5", + ] + ) + + #expect(environment.hostname == "127.0.0.1") + #expect(environment.port == 9000) + } + + @Test func throwsHelpfulErrorWhenHostValuesAreMissing() { + #expect(throws: CoreError.missingEnvironmentValue("HOST or BACKEND_HOST")) { + try Environment.load( + from: [ + "AWS_REGION": "us-east-1", + "GENERATED_IMAGES_BUCKET": "bytesized-generated-images", + "IMAGE_GEN_PREFIX": "generated/v2", + "OPENAI_API_KEY": "secret", + "OPENAI_IMAGE_MODEL": "gpt-image-1.5", + "PORT": "8080", + ] + ) + } + } + + @Test func throwsInvalidPortForAliasValues() { + #expect(throws: CoreError.invalidPort("not-a-port")) { + try Environment.load( + from: [ + "AWS_REGION": "us-east-1", + "BACKEND_HOST": "127.0.0.1", + "BACKEND_PORT": "not-a-port", + "GENERATED_IMAGES_BUCKET": "bytesized-generated-images", + "IMAGE_GEN_PREFIX": "generated/v2", + "OPENAI_API_KEY": "secret", + "OPENAI_IMAGE_MODEL": "gpt-image-1.5", + ] + ) + } + } +} diff --git a/Backend/Tests/CoreTests/KeyFactoryTests.swift b/Backend/Tests/CoreTests/KeyFactoryTests.swift new file mode 100644 index 0000000..631f441 --- /dev/null +++ b/Backend/Tests/CoreTests/KeyFactoryTests.swift @@ -0,0 +1,35 @@ +import Testing + +@testable import Core + +struct KeyFactoryTests { + @Test func pageImageKeyBuildsStableReadableCachePath() { + let keyFactory = KeyFactory() + + let key = keyFactory.pageImageKey( + prefix: "/generated/v2/", + context: PageContext( + pagePath: "/posts/Cafe-con-leche/", + pageType: .article + ), + countryName: "Côte d'Ivoire" + ) + + #expect( + key == "generated/v2/page-cache/article/posts/cafe-con-leche-cote-d-ivoire.png") + } + + @Test func pageImageKeyFallsBackToRootAndAnywhere() { + let keyFactory = KeyFactory() + + let key = keyFactory.pageImageKey( + prefix: "generated/v2", + context: PageContext( + pagePath: "/", + pageType: .index + ) + ) + + #expect(key == "generated/v2/page-cache/index/root-anywhere.png") + } +} diff --git a/Backend/railway.toml b/Backend/railway.toml new file mode 100644 index 0000000..f94ae76 --- /dev/null +++ b/Backend/railway.toml @@ -0,0 +1,6 @@ +[build] +builder = "DOCKERFILE" +dockerfilePath = "Dockerfile" + +[deploy] +healthcheckPath = "/health" diff --git a/BytesizedCafe/Package.swift b/BytesizedCafe/Package.swift new file mode 100644 index 0000000..391a1c9 --- /dev/null +++ b/BytesizedCafe/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 6.2 + +import PackageDescription + +let package = Package( + name: "BytesizedCafe", + platforms: [ + .macOS(.v13) + ], + products: [ + .executable(name: "BytesizedCafe", targets: ["BytesizedCafe"]) + ], + dependencies: [ + .package(url: "https://github.com/swiftwasm/JavaScriptKit.git", from: "0.46.5"), + .package(url: "https://github.com/pvzig/parcel", from: "0.2.0"), + ], + targets: [ + .executableTarget( + name: "BytesizedCafe", + dependencies: [ + .product(name: "JavaScriptEventLoop", package: "JavaScriptKit"), + .product(name: "JavaScriptKit", package: "JavaScriptKit"), + .product(name: "Parcel", package: "parcel"), + ] + ) + ] +) diff --git a/BytesizedCafe/Sources/BytesizedCafe/App.swift b/BytesizedCafe/Sources/BytesizedCafe/App.swift new file mode 100644 index 0000000..42e0156 --- /dev/null +++ b/BytesizedCafe/Sources/BytesizedCafe/App.swift @@ -0,0 +1,12 @@ +import Foundation +import JavaScriptEventLoop + +@main +struct App { + static func main() { + #if arch(wasm32) + JavaScriptEventLoop.installGlobalExecutor() + BytesizedCafe.start() + #endif + } +} diff --git a/BytesizedCafe/Sources/BytesizedCafe/BytesizedCafe.swift b/BytesizedCafe/Sources/BytesizedCafe/BytesizedCafe.swift new file mode 100644 index 0000000..be682a8 --- /dev/null +++ b/BytesizedCafe/Sources/BytesizedCafe/BytesizedCafe.swift @@ -0,0 +1,118 @@ +import Foundation +import JavaScriptKit +import Parcel + +struct BytesizedCafe { + enum ObjectKeys: String { + case started + case state + } + + enum StorageKeys: String { + case imageURL = "bytesized-cafe-image-url" + case pagePath = "bytesized-cafe-page-path" + case pageType = "bytesized-cafe-page-type" + } + + private static var client: Client { + #if arch(wasm32) + Client() + #else + fatalError("BytesizedCafe only supports Parcel's browser client on wasm32 builds") + #endif + } + + static func start() { + guard + let root = mount(), + let configuration = Config(root: root), + root.dataset.object?[ObjectKeys.started.rawValue].string + == PreparationState.ordered.rawValue + else { + return + } + + if let cachedImageURL = cachedImageURL(for: configuration) { + applyImage(cachedImageURL, root: root) + updateState(.ready, root: root) + return + } + + updateState(.preparing, root: root) + + Task { + do { + let generatedImage = try await requestImage(for: configuration) + cacheImageURL(generatedImage.url, for: configuration) + applyImage(generatedImage.url, root: root) + updateState(.ready, root: root) + } catch { + updateState(.ordered, root: root) + } + } + } + + private static func mount() -> JSObject? { + JSObject.global.document.getElementById("bytesized-cafe-app").object + } + + private static func requestImage(for configuration: Config) async throws + -> GenerateImageResponse + { + try await client.send( + .post( + configuration.apiURL, + body: GenerateImageRequest(context: configuration.pageContext) + ), + as: GenerateImageResponse.self + ).value + } + + private static func cachedImageURL(for configuration: Config) -> URL? { + guard + let sessionStorage, + sessionStorage.getItem?(StorageKeys.pagePath.rawValue).string + == configuration.pageContext.pagePath, + sessionStorage.getItem?(StorageKeys.pageType.rawValue).string + == configuration.pageContext.pageType, + let imageURLString = sessionStorage.getItem?(StorageKeys.imageURL.rawValue).string + else { + return nil + } + + return URL(string: imageURLString) + } + + private static func cacheImageURL(_ url: URL, for configuration: Config) { + guard let sessionStorage else { + return + } + + _ = sessionStorage.setItem?( + StorageKeys.pagePath.rawValue, configuration.pageContext.pagePath) + _ = sessionStorage.setItem?( + StorageKeys.pageType.rawValue, configuration.pageContext.pageType) + _ = sessionStorage.setItem?(StorageKeys.imageURL.rawValue, url.absoluteString) + } + + private static func applyImage(_ url: URL, root: JSObject) { + guard + let image = root.querySelector?(".bytesized-cafe-image").object + else { + return + } + + image["src"] = JSValue.string(url.absoluteString) + image["alt"] = JSValue.string( + "👨🏻‍🍳🍲😋") + } + + private static func updateState(_ state: PreparationState, root: JSObject) { + root.dataset.object?[ObjectKeys.started.rawValue] = JSValue.string(state.rawValue) + root.dataset.object?[ObjectKeys.state.rawValue] = JSValue.string(state.rawValue) + } + + private static var sessionStorage: JSObject? { + JSObject.global.sessionStorage.object + } +} diff --git a/BytesizedCafe/Sources/BytesizedCafe/Model/Config.swift b/BytesizedCafe/Sources/BytesizedCafe/Model/Config.swift new file mode 100644 index 0000000..6a913e9 --- /dev/null +++ b/BytesizedCafe/Sources/BytesizedCafe/Model/Config.swift @@ -0,0 +1,22 @@ +import Foundation +import JavaScriptKit + +struct Config { + let apiURL: URL + let pageContext: PageContext + + init?(root: JSObject) { + guard + let dataset = root.dataset.object, + let apiURLString = dataset["apiUrl"].string, + let apiURL = URL(string: apiURLString), + let pagePath = dataset["pagePath"].string, + let pageType = dataset["pageType"].string + else { + return nil + } + + self.apiURL = apiURL + self.pageContext = PageContext(pagePath: pagePath, pageType: pageType) + } +} diff --git a/BytesizedCafe/Sources/BytesizedCafe/Model/GenerateImageRequest.swift b/BytesizedCafe/Sources/BytesizedCafe/Model/GenerateImageRequest.swift new file mode 100644 index 0000000..2aba968 --- /dev/null +++ b/BytesizedCafe/Sources/BytesizedCafe/Model/GenerateImageRequest.swift @@ -0,0 +1,3 @@ +struct GenerateImageRequest: Encodable { + let context: PageContext +} diff --git a/BytesizedCafe/Sources/BytesizedCafe/Model/GenerateImageResponse.swift b/BytesizedCafe/Sources/BytesizedCafe/Model/GenerateImageResponse.swift new file mode 100644 index 0000000..7187aec --- /dev/null +++ b/BytesizedCafe/Sources/BytesizedCafe/Model/GenerateImageResponse.swift @@ -0,0 +1,5 @@ +import Foundation + +struct GenerateImageResponse: Decodable { + let url: URL +} diff --git a/BytesizedCafe/Sources/BytesizedCafe/Model/PageContext.swift b/BytesizedCafe/Sources/BytesizedCafe/Model/PageContext.swift new file mode 100644 index 0000000..4fadea7 --- /dev/null +++ b/BytesizedCafe/Sources/BytesizedCafe/Model/PageContext.swift @@ -0,0 +1,4 @@ +struct PageContext: Codable { + let pagePath: String + let pageType: String +} diff --git a/BytesizedCafe/Sources/BytesizedCafe/Model/PreparationState.swift b/BytesizedCafe/Sources/BytesizedCafe/Model/PreparationState.swift new file mode 100644 index 0000000..b2df910 --- /dev/null +++ b/BytesizedCafe/Sources/BytesizedCafe/Model/PreparationState.swift @@ -0,0 +1,5 @@ +enum PreparationState: String { + case ordered + case preparing + case ready +} diff --git a/LICENSE b/LICENSE index 42844d2..7b54986 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 Peter Zignego +Copyright (c) 2026 Peter Zignego Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Package.swift b/Package.swift index f768546..181f063 100644 --- a/Package.swift +++ b/Package.swift @@ -1,21 +1,20 @@ -// swift-tools-version:5.5 -// The swift-tools-version declares the minimum version of Swift required to build this package. +// swift-tools-version: 6.2 import PackageDescription let package = Package( name: "bytesized.co", platforms: [ - .macOS(.v12) + .macOS(.v15) ], products: [ .executable(name: "bytesized", targets: ["bytesized"]) ], dependencies: [ - .package(url: "https://github.com/pvzig/Publish", .branch("master")), + .package(url: "https://github.com/pvzig/Publish", branch: "master"), .package(url: "https://github.com/johnsundell/plot", from: "0.14.0"), .package(url: "https://github.com/JohnSundell/Splash.git", from: "0.16.0"), - .package(name: "CommonMark", url: "https://github.com/pvzig/CommonMark", .branch("main")) + .package(url: "https://github.com/pvzig/CommonMark", branch: "main"), ], targets: [ .executableTarget( @@ -24,7 +23,7 @@ let package = Package( .product(name: "Plot", package: "plot"), "Publish", "CommonMark", - "Splash" + "Splash", ]) ] ) diff --git a/README.md b/README.md index 4b6475d..887e489 100644 --- a/README.md +++ b/README.md @@ -3,25 +3,134 @@ Source for [bytesized.co](https://www.bytesized.co). bytesized.co is statically generated using [Publish](https://github.com/JohnSundell/Publish), a static site generator written in Swift. -## Testing Locally +## Local Development + +The repo-root `justfile` is the preferred entry point for common local tasks. + +Install the `just` task runner if you do not already have it: + ```bash -publish run +brew install just ``` -## Publishing to S3 +For local setup, copy [`.ENV.example`](.ENV.example) to `.ENV` and fill in the values used by the local stack. -### Manually: +### Site Generator ```bash -swift run -c release bytesized -swift run -c release bytesized --deploy +just site ``` -### With Github Actions: +### Full Local Stack +To rebuild the SwiftWASM app, regenerate the site with a localhost backend URL, run the backend, and serve `Output/` in one command: -https://github.com/pvzig/bytesized.co/blob/master/.github/workflows/deploy.yml +```bash +just local +``` +`just local` requires the following `.ENV` values: +``` +- SITE_HOST +- SITE_PORT +- BACKEND_HOST +- BACKEND_PORT +- GENERATED_IMAGES_BUCKET +- OPENAI_API_KEY +- OPENAI_IMAGE_MODEL +- IMAGE_GEN_PREFIX +- AWS_REGION +- AWS_ACCESS_KEY_ID +- AWS_SECRET_ACCESS_KEY +``` -Configure the following secrets in Github (/repo/settings/secrets) -- `AWS_S3_BUCKET` -- `CLOUDFRONT_DISTRIBUTION_ID` +### SwiftWASM App +- The SwiftWASM package lives in `BytesizedCafe/` +- The app is built with JavaScriptKit's `PackageToJS` SwiftPM plugin +- The page loads `/bytesized-cafe-app/index.js` directly as a module with the request/render logic lives in the SwiftWASM target + +To build the SwiftWASM app before generating the site: +1. Install a WebAssembly Swift SDK first by following the official guide: https://www.swift.org/documentation/articles/wasm-getting-started.html +2. Install Binaryen if you want `wasm-opt` optimizations during packaging + +```bash +just wasm +``` +which runs: + +```bash +swift package --swift-sdk js --product BytesizedCafe -c release --use-cdn +``` + +It copies the generated package output from `BytesizedCafe/.build/plugins/PackageToJS/outputs/Package/` into the repo-root `bytesized-cafe-app/` folder, which the site generator then publishes at `/bytesized-cafe-app/`. + +### Hummingbird Backend +Run the backend from `Backend/` with your generated-images bucket and OpenAI key configured: + +```bash +cd Backend +HOST=127.0.0.1 \ +PORT=8080 \ +GENERATED_IMAGES_BUCKET= \ +OPENAI_API_KEY= \ +OPENAI_IMAGE_MODEL= \ +IMAGE_GEN_PREFIX= \ +AWS_REGION= \ +AWS_ACCESS_KEY_ID= \ +AWS_SECRET_ACCESS_KEY= \ +swift run Server +``` +Image generation is capped at 15 images per UTC day. The backend stores a stable per-page image key so repeat requests for the same page reuse the existing image instead of generating again. When the daily budget is exhausted for a first-time page request, the server assigns that page a random previously generated image. +Freshly generated image keys are still partitioned by UTC date under `IMAGE_GEN_PREFIX/YYYY/MM/DD/`, and the stable page cache lives under `IMAGE_GEN_PREFIX/page-cache/`. + +Point the site generator at the backend API when building the HTML: + +```bash +just site-local +``` + +That recipe requires `BYTESIZED_CAFE_API_URL`, for example `BYTESIZED_CAFE_API_URL=http://127.0.0.1:8080/api/cafe/generate just site-local`. + +### Railway Deployment +Production deployment runs from [`.github/workflows/deploy.yml`](.github/workflows/deploy.yml) when you push to the deployment branch. + +The backend job runs on `ubuntu-latest`, syncs Railway runtime variables from GitHub Actions, and then deploys `Backend/` to Railway with `railway up Backend --ci --path-as-root`. It uses the checked-in [`Backend/railway.toml`](Backend/railway.toml), [`Backend/Dockerfile`](Backend/Dockerfile), and [`Scripts/sync-railway-backend-variables.sh`](Scripts/sync-railway-backend-variables.sh) for build and deploy configuration. Railway owns the runtime container and public HTTPS endpoint. + +Create one empty Railway service for the backend. Disable Railway’s own GitHub autodeploy for the service so pushes do not trigger duplicate backend deployments. The deploy workflow syncs these runtime variables into Railway before each deployment: +- `GENERATED_IMAGES_BUCKET` +- `OPENAI_API_KEY` +- `OPENAI_IMAGE_MODEL` +- `IMAGE_GEN_PREFIX` +- `AWS_REGION` - `AWS_ACCESS_KEY_ID` - `AWS_SECRET_ACCESS_KEY` + +To seed the overlapping GitHub Actions repository variables and secrets from the local [`.ENV`](.ENV), run: + +```bash +./Scripts/sync-github-actions-config.sh +``` + +That script syncs `AWS_REGION`, `GENERATED_IMAGES_BUCKET`, `OPENAI_IMAGE_MODEL`, `IMAGE_GEN_PREFIX`, `AWS_S3_BUCKET`, `OPENAI_API_KEY`, `AWS_ACCESS_KEY_ID`, and `AWS_SECRET_ACCESS_KEY` using `gh`, streaming secret values over stdin so they do not appear in command arguments. + +Set these GitHub Actions repository variables: +- `RAILWAY_PROJECT_ID` +- `RAILWAY_ENVIRONMENT_NAME` +- `RAILWAY_SERVICE_NAME` +- `BYTESIZED_CAFE_API_URL` (your backend domain) +- `AWS_REGION` +- `GENERATED_IMAGES_BUCKET` +- `OPENAI_IMAGE_MODEL` +- `IMAGE_GEN_PREFIX` +and these GitHub Actions secrets: +- `RAILWAY_TOKEN` +- `OPENAI_API_KEY` +- `AWS_ACCESS_KEY_ID` +- `AWS_SECRET_ACCESS_KEY` +- `AWS_S3_BUCKET` + +For local Docker validation and GitHub workflow linting, run: + +```bash +just validate-deployment +``` + +That builds the backend Docker image locally and validates GitHub Actions workflow YAML parsing. +The GitHub Actions validation workflow uses the same `just validate-deployment` recipe. diff --git a/Resources/css/styles.css b/Resources/css/styles.css index fa9e73d..107c980 100644 --- a/Resources/css/styles.css +++ b/Resources/css/styles.css @@ -117,6 +117,27 @@ a:hover { border-bottom: none; } +.bytesized-cafe-app { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 10px; +} + +.bytesized-cafe-image { + display: block; + width: min(40vw, 160px); + height: min(40vw, 160px); + object-fit: contain; + filter: drop-shadow(0 14px 18px rgba(0, 0, 0, 0.10)); +} + +.bytesized-cafe-app[data-state="preparing"] .bytesized-cafe-image { + animation: simmer 3.6s ease-in-out infinite; + transform-origin: 50% 78%; + will-change: transform; +} + .title { font-family: 'Cartridge Bold', serif; font-weight: 400; @@ -258,3 +279,48 @@ code { --code-background-color: #393e46; } } + +@media (prefers-reduced-motion: reduce) { + .bytesized-cafe-app[data-state="preparing"] .bytesized-cafe-image { + animation: none; + } +} + +@keyframes simmer { + 0%, + 14%, + 100% { + transform: translate3d(0px, 0px, 0px) rotate(0deg) scale(1); + } + 20% { + transform: translate3d(0px, -4px, 0px) rotate(0deg) scale(1.02); + } + 22% { + transform: translate3d(-2px, -5px, 0px) rotate(-1deg) scale(1.02); + } + 24% { + transform: translate3d(2px, -3px, 0px) rotate(0.9deg) scale(1.02); + } + 26% { + transform: translate3d(-1px, -5px, 0px) rotate(-0.6deg) scale(1.02); + } + 28%, + 52% { + transform: translate3d(0px, -2px, 0px) rotate(0deg) scale(1.01); + } + 70% { + transform: translate3d(0px, -4px, 0px) rotate(0deg) scale(1.02); + } + 72% { + transform: translate3d(1px, -5px, 0px) rotate(0.7deg) scale(1.02); + } + 74% { + transform: translate3d(-2px, -3px, 0px) rotate(-0.9deg) scale(1.02); + } + 76% { + transform: translate3d(1px, -4px, 0px) rotate(0.5deg) scale(1.01); + } + 82% { + transform: translate3d(0px, -1px, 0px) rotate(0deg) scale(1.005); + } +} diff --git a/Resources/images/logo/1.png b/Resources/images/logo/1.png deleted file mode 100644 index 7afebe6..0000000 Binary files a/Resources/images/logo/1.png and /dev/null differ diff --git a/Resources/images/logo/10.png b/Resources/images/logo/10.png deleted file mode 100644 index 9f37c44..0000000 Binary files a/Resources/images/logo/10.png and /dev/null differ diff --git a/Resources/images/logo/11.png b/Resources/images/logo/11.png deleted file mode 100644 index 3f846ff..0000000 Binary files a/Resources/images/logo/11.png and /dev/null differ diff --git a/Resources/images/logo/12.png b/Resources/images/logo/12.png deleted file mode 100644 index 2e1f82a..0000000 Binary files a/Resources/images/logo/12.png and /dev/null differ diff --git a/Resources/images/logo/13.png b/Resources/images/logo/13.png deleted file mode 100644 index 42ae08a..0000000 Binary files a/Resources/images/logo/13.png and /dev/null differ diff --git a/Resources/images/logo/14.png b/Resources/images/logo/14.png deleted file mode 100644 index 46cc7ef..0000000 Binary files a/Resources/images/logo/14.png and /dev/null differ diff --git a/Resources/images/logo/15.png b/Resources/images/logo/15.png deleted file mode 100644 index 397cbe9..0000000 Binary files a/Resources/images/logo/15.png and /dev/null differ diff --git a/Resources/images/logo/16.png b/Resources/images/logo/16.png deleted file mode 100644 index a553b2c..0000000 Binary files a/Resources/images/logo/16.png and /dev/null differ diff --git a/Resources/images/logo/17.png b/Resources/images/logo/17.png deleted file mode 100644 index 63ba533..0000000 Binary files a/Resources/images/logo/17.png and /dev/null differ diff --git a/Resources/images/logo/18.png b/Resources/images/logo/18.png deleted file mode 100644 index 7de720b..0000000 Binary files a/Resources/images/logo/18.png and /dev/null differ diff --git a/Resources/images/logo/19.png b/Resources/images/logo/19.png deleted file mode 100644 index 4ac4640..0000000 Binary files a/Resources/images/logo/19.png and /dev/null differ diff --git a/Resources/images/logo/2.png b/Resources/images/logo/2.png deleted file mode 100644 index d7f2abc..0000000 Binary files a/Resources/images/logo/2.png and /dev/null differ diff --git a/Resources/images/logo/20.png b/Resources/images/logo/20.png deleted file mode 100644 index f942f72..0000000 Binary files a/Resources/images/logo/20.png and /dev/null differ diff --git a/Resources/images/logo/21.png b/Resources/images/logo/21.png deleted file mode 100644 index 1a6aab2..0000000 Binary files a/Resources/images/logo/21.png and /dev/null differ diff --git a/Resources/images/logo/22.png b/Resources/images/logo/22.png deleted file mode 100644 index 4c92c43..0000000 Binary files a/Resources/images/logo/22.png and /dev/null differ diff --git a/Resources/images/logo/23.png b/Resources/images/logo/23.png deleted file mode 100644 index 024ce4d..0000000 Binary files a/Resources/images/logo/23.png and /dev/null differ diff --git a/Resources/images/logo/24.png b/Resources/images/logo/24.png deleted file mode 100644 index 0fb7bbc..0000000 Binary files a/Resources/images/logo/24.png and /dev/null differ diff --git a/Resources/images/logo/25.png b/Resources/images/logo/25.png deleted file mode 100644 index 274c19b..0000000 Binary files a/Resources/images/logo/25.png and /dev/null differ diff --git a/Resources/images/logo/26.png b/Resources/images/logo/26.png deleted file mode 100644 index 552e890..0000000 Binary files a/Resources/images/logo/26.png and /dev/null differ diff --git a/Resources/images/logo/27.png b/Resources/images/logo/27.png deleted file mode 100644 index 24c4aec..0000000 Binary files a/Resources/images/logo/27.png and /dev/null differ diff --git a/Resources/images/logo/28.png b/Resources/images/logo/28.png deleted file mode 100644 index c013744..0000000 Binary files a/Resources/images/logo/28.png and /dev/null differ diff --git a/Resources/images/logo/29.png b/Resources/images/logo/29.png deleted file mode 100644 index 5063d64..0000000 Binary files a/Resources/images/logo/29.png and /dev/null differ diff --git a/Resources/images/logo/3.png b/Resources/images/logo/3.png deleted file mode 100644 index 6c454ed..0000000 Binary files a/Resources/images/logo/3.png and /dev/null differ diff --git a/Resources/images/logo/30.png b/Resources/images/logo/30.png deleted file mode 100644 index a02cb74..0000000 Binary files a/Resources/images/logo/30.png and /dev/null differ diff --git a/Resources/images/logo/31.png b/Resources/images/logo/31.png deleted file mode 100644 index 793e0bb..0000000 Binary files a/Resources/images/logo/31.png and /dev/null differ diff --git a/Resources/images/logo/4.png b/Resources/images/logo/4.png deleted file mode 100644 index bb15a9c..0000000 Binary files a/Resources/images/logo/4.png and /dev/null differ diff --git a/Resources/images/logo/5.png b/Resources/images/logo/5.png deleted file mode 100644 index 9e9f4e8..0000000 Binary files a/Resources/images/logo/5.png and /dev/null differ diff --git a/Resources/images/logo/6.png b/Resources/images/logo/6.png deleted file mode 100644 index aed0748..0000000 Binary files a/Resources/images/logo/6.png and /dev/null differ diff --git a/Resources/images/logo/7.png b/Resources/images/logo/7.png deleted file mode 100644 index c4bba62..0000000 Binary files a/Resources/images/logo/7.png and /dev/null differ diff --git a/Resources/images/logo/8.png b/Resources/images/logo/8.png deleted file mode 100644 index 68099f7..0000000 Binary files a/Resources/images/logo/8.png and /dev/null differ diff --git a/Resources/images/logo/9.png b/Resources/images/logo/9.png deleted file mode 100644 index 2f906e9..0000000 Binary files a/Resources/images/logo/9.png and /dev/null differ diff --git a/Resources/images/preparing.png b/Resources/images/preparing.png new file mode 100644 index 0000000..e624dc8 Binary files /dev/null and b/Resources/images/preparing.png differ diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..3ecb978 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,242 @@ +# Spec: SwiftWASM + Hummingbird + S3 Image Delivery + +## 1. Objective +Implement a web app where: +- The page loads in the browser as a SwiftWASM app. +- The app automatically requests an image on the home page, article pages, and paginated archive pages. +- A same-session revisit of the same page reuses that page's last returned image from client-side session storage when available. +- The backend persists a stable per-page image key so repeat requests for the same page and request country reuse the existing image instead of generating a new one. +- When the daily generation budget is exhausted, the backend returns a random previously generated image instead of requesting a new one. +- The backend waits for image generation to finish before replying. +- The final image is rendered from a public S3 HTTPS URL. +- The image is generated by the OpenAI image generation API using the fixed model `gpt-image-1.5`. +- The generation prompt should use the request's origin country when the server can resolve it from the client IP through `country.is`, and otherwise fall back to a generic worldwide prompt. +- The backend performs `country.is` lookups and OpenAI image-generation requests with AsyncHTTPClient. +- The backend uses a long-lived Hummingbird server. + +## 2. System Overview + +### 2.1 Components +- **Frontend:** SwiftWASM app using Parcel for typed HTTP JSON requests in the browser. +- **Backend:** Hummingbird server exposing a single synchronous `POST` endpoint. +- **Storage/delivery:** A dedicated public S3 bucket exposing generated PNG objects under a dedicated prefix. + +### 2.2 High-Level Flow +1. The browser loads the SwiftWASM bundle and page HTML. +2. The app reads the page context from the mount element. +3. If session storage already contains an image URL for the same page path and page type, the app reuses that URL and skips the API call. +4. Otherwise, the app calls `POST ` with page context. +5. The server derives the client IP from proxy forwarding headers, preferring `X-Real-IP` when present and otherwise falling back to `X-Forwarded-For`, then looks up the origin country with `country.is`. +6. The server validates input and checks for a stable page-cache key derived from page context and resolved country before considering a new generation. +7. If the page-cache key already exists, the server returns that image immediately. +8. Otherwise, the server counts generated PNG objects already present under the current UTC day prefix in S3 to decide whether its soft daily generation budget has remaining capacity. +9. If budget remains, the server creates a fresh unique dated image key, builds a country-aware prompt when country lookup succeeded, calls OpenAI, uploads the PNG to S3, writes the same image to the stable page-cache key, and returns `200 OK`. +10. If the daily budget is exhausted, the server selects a random existing generated PNG from S3, copies it to the stable page-cache key, and returns `200 OK` with that page-cache image instead. +9. The app swaps the placeholder image source to the returned or cached URL. +10. On successful API responses, the app stores the returned image URL in session storage for future visits to the same page in the current browser session. + +### 2.3 Published SwiftWASM Assets +- The `BytesizedCafe` SwiftWASM package is built into the repo-root `bytesized-cafe-app/` directory. +- The site generator publishes that directory at `/bytesized-cafe-app/`. +- Published asset paths must preserve the generated nested package layout, including `/bytesized-cafe-app/platforms/browser.js`. +- The `BytesizedCafe` package declares macOS 13 or newer for host-side package resolution because its Parcel dependency requires that minimum deployment target; the browser app itself is still built through the installed WebAssembly Swift SDK. + +## 3. S3 Design + +### 3.1 Canonical Public Origin +The backend derives the public image origin from `GENERATED_IMAGES_BUCKET` and `AWS_REGION` as: + +`https://.s3..amazonaws.com` + +Do not use the S3 website endpoint for generated image URLs. + +### 3.2 Bucket Access Model +- Keep S3 Object Ownership set to `Bucket owner enforced`. +- Keep object ACLs disabled. +- Public read comes from bucket policy, not object ACLs. +- Store generated images in a dedicated public S3 bucket separate from the static site bucket. +- Keep generated images under `IMAGE_GEN_PREFIX`. +- Grant anonymous `s3:GetObject` on `arn:aws:s3::://*`. +- Do not upload with `public-read` ACLs. + +### 3.3 Object Key Format +- Freshly generated image: + - `{IMAGE_GEN_PREFIX}/{YYYY}/{MM}/{DD}/{UUID}-{country-slug}.png` when the request country is known + - `{IMAGE_GEN_PREFIX}/{YYYY}/{MM}/{DD}/{UUID}.png` when the request country is not known +- Stable page-cache image: + - `{IMAGE_GEN_PREFIX}/page-cache/{pageType}/{normalized-page-path}-{country-slug}.png` when the request country is known + - `{IMAGE_GEN_PREFIX}/page-cache/{pageType}/{normalized-page-path}-anywhere.png` when the request country is not known +- Random fallback image: + - Prefer an existing PNG under `IMAGE_GEN_PREFIX/` whose key ends in the current request's `-{country-slug}.png` + - Fall back to any existing PNG under `IMAGE_GEN_PREFIX/` when no country-matching image is available + +Rules: +- Fresh generation keys must not be derived from page context. +- Stable page-cache keys must be derived from page context and resolved country. +- API responses should prefer the stable page-cache key whenever one exists or is created during the request. + +### 3.4 Object Metadata +When uploading a freshly generated image: +- `Content-Type: image/png` +- `Cache-Control: public, max-age=31536000, immutable` + +### 3.5 Lifecycle Policy +Configure S3 Lifecycle expiration to prevent unbounded storage growth: +- Expire `IMAGE_GEN_PREFIX/` after 30 days if regeneration on cache miss is acceptable. + +## 4. API Design + +### 4.1 Routing +This API uses a single action endpoint: +- `POST /api/cafe/generate` triggers generation or fallback selection. +- `OPTIONS /api/cafe/generate` is handled by Hummingbird CORS middleware. + +### 4.2 CORS +Enable CORS on the server endpoint for browser access: +- Allowed methods: `POST`, `OPTIONS` +- Allowed headers: `Content-Type` +- Allowed origins: site origin(s) used to host the SwiftWASM app + +## 5. API Contract + +### 5.1 `POST ` +Request JSON: + +```json +{ + "context": { + "pagePath": "/posts/example-article", + "pageType": "article" + } +} +``` + +`pageType` must be one of: +- `index` +- `article` +- `archive` + +Response: +- Status: `200 OK` +- Body: + +```json +{ + "url": "https:///generated/v2/page-cache/article/posts/example-article-france.png" +} +``` + +Rules: +- `url` is the final public image URL and must use the generated-images bucket public origin. +- The response may return a stable per-page cache key when the page already has an assigned image. +- Return `200` only after the image has been uploaded successfully or a random fallback image has been selected successfully. +- Invalid input returns `4xx`. +- If the daily budget is exhausted and no fallback image exists, return `503`. +- Terminal upstream failures return `5xx`. + +## 6. Server Behavior + +### 6.1 Hummingbird Server Responsibilities +- Parse and validate input JSON. +- Encapsulate S3 operations behind one `S3ImageStore` client object that owns the bucket configuration and AWS client lifecycle for image upload and lookup operations. +- Resolve the client IP address by preferring `X-Real-IP` when present and otherwise falling back to `X-Forwarded-For`. +- Look up the request origin country with `https://api.country.is/{ip}` and convert the returned region code into an English country name when available. +- Derive a stable page-cache key from page context and resolved country, and return it immediately when that object already exists in S3. +- Check the soft daily generation budget by counting PNG objects already present under the current UTC date prefix in S3. +- Build the public `url`. +- When budget remains: + - Generate a fresh unique image key. + - Build the prompt as a single random dish popular in the request country. + - Include a normalized `-{country-slug}` suffix in the generated key when country lookup succeeds. + - Instruct the model to prefer specific, visually distinct local dishes over generic national defaults, and to avoid repeatedly defaulting to globally common fast food unless it is genuinely the random choice. + - Fall back to the same prompt structure scoped to somewhere in the world when the client IP or country cannot be resolved. + - Call the OpenAI image generation API with model `gpt-image-1.5`. + - Allow up to 120 seconds for the OpenAI image generation request to complete before failing the server request. + - Upload the PNG to the generated image key used for the dated generation pool. + - Upload the same PNG to the stable page-cache key. + - Return the page-cache `url`. +- When budget is exhausted: + - Prefer a random existing generated PNG key from S3 whose key suffix matches the current request country. + - Fall back to a random existing generated PNG key from S3 when no country-matching key is available. + - Copy the selected fallback image to the stable page-cache key without calling OpenAI. + - Return the page-cache `url`. + +## 7. Frontend Behavior +- Show a loading placeholder immediately. +- Install the JavaScript event loop executor before spawning async startup work. +- Read page context from the mount element. +- If session storage contains a URL for the same page path and page type, reuse that URL and skip the API call. +- Otherwise, start a single `POST` request to the configured API URL. +- When the request succeeds, swap the placeholder image source to the returned `url`. +- Persist the returned image URL in session storage keyed to the current page so the next same-session visit of that page can reuse it. +- Do not poll. + +## 8. Environment Variables + +### 8.1 Hummingbird Server +- `GENERATED_IMAGES_BUCKET` +- `OPENAI_API_KEY` +- `OPENAI_IMAGE_MODEL` +- `IMAGE_GEN_PREFIX` +- `AWS_REGION` +- `AWS_ACCESS_KEY_ID` +- `AWS_SECRET_ACCESS_KEY` +- `HOST` +- `PORT` + +The AWS values may also be supplied through another AWS SDK credential-chain source, but Railway deployment should provide explicit environment variables or equivalent secret-backed injection. +The backend should load these values through one shared environment model used by both the generation service configuration and server bind configuration. +The backend environment model should read process environment values through `apple/swift-configuration`. +Local repo tooling may provide `BACKEND_HOST` and `BACKEND_PORT` as aliases for the backend runtime `HOST` and `PORT` values. +Backend request validation, environment validation, and terminal upstream failures should use one shared `CoreError` enum in the `Core` module. + +### 8.2 Site Build +- `BYTESIZED_CAFE_API_URL` + +### 8.3 Site Deploy +- `AWS_S3_BUCKET` + +## 9. Validation +The implementation is considered complete when: +- A same-session revisit of the same page reuses the last returned image URL from session storage without making a new backend request. +- A backend request for a page that already has a stable page-cache object returns that existing image URL without making a new OpenAI request. +- The backend returns `200` only after a fresh image upload succeeds or a random fallback image has been selected. +- When the daily budget is exhausted, the backend returns a random existing generated image instead of making a new OpenAI request. +- Fresh generations use the request origin country in the prompt when the server can resolve it from the client IP, and otherwise fall back to the generic worldwide prompt. +- Fresh generations include a country slug suffix in the image key when the request country is known. +- When the daily budget is exhausted, fallback selection prefers existing images whose keys match the current request country and otherwise falls back to any existing image. +- The backend persists deterministic per-page cache keys separately from the dated generation pool. +- No DynamoDB, SQS, DLQ, or status-object flow is required by the active path. + +## 10. Deployment + +### 10.1 Container Build +- The backend container image is built from `Backend/` using the checked-in `Backend/Dockerfile`. +- The checked-in `Backend/railway.toml` codifies the Railway deploy settings that should live in source control, currently the Dockerfile builder and `/health` healthcheck. +- The checked-in `Scripts/sync-github-actions-config.sh` script codifies how overlapping GitHub Actions repository variables and secrets can be synchronized from the local `.ENV` file. +- The checked-in `Scripts/sync-railway-backend-variables.sh` script codifies how GitHub Actions synchronizes backend runtime variables into Railway before deployment. +- The deployable product is the `Server` executable. +- Railway builds and runs the production image from GitHub pushes, targeting the backend service with `railway up Backend --ci --path-as-root`. +- The runtime base image remains `swift:6.2.4-bookworm-slim`. +- Deployment config changes are validated with `just validate-deployment`, which delegates to `./Scripts/validate-deployment-config.sh` to build the Docker image and validate workflow YAML parsing. + +### 10.2 Railway Infrastructure +- Railway hosts the public backend service, injects runtime environment variables, and exposes a healthchecked HTTPS endpoint for the `Server` container. +- The backend Railway service can be created as an empty service because GitHub Actions uploads `Backend/` directly during deployment. +- The Railway service should define a stable custom domain so the static site can build against a fixed `BYTESIZED_CAFE_API_URL`. +- The GitHub Actions workflow under `.github/workflows/deploy.yml` is the production deployment path and is intended to run on pushes to the primary deployment branch. +- The backend deploy job authenticates with a Railway project token, synchronizes the backend runtime variables into Railway, and deploys the `Backend/` directory directly to the configured Railway project, environment, and service. +- Railway service-level GitHub autodeploy should be disabled when the GitHub Actions workflow is the active deployment path, to avoid duplicate backend deployments from the same push. +- GitHub Actions repository variables and secrets are the source of truth for the backend runtime variables `GENERATED_IMAGES_BUCKET`, `OPENAI_API_KEY`, `OPENAI_IMAGE_MODEL`, `IMAGE_GEN_PREFIX`, `AWS_REGION`, `AWS_ACCESS_KEY_ID`, and `AWS_SECRET_ACCESS_KEY`. +- The overlapping GitHub Actions repository variables and secrets can be seeded from the local `.ENV` file with `Scripts/sync-github-actions-config.sh`, but deployment-only values such as `RAILWAY_PROJECT_ID`, `RAILWAY_ENVIRONMENT_NAME`, `RAILWAY_SERVICE_NAME`, `RAILWAY_TOKEN`, and `BYTESIZED_CAFE_API_URL` remain manually managed because they do not belong in the local development env file. +- The backend deploy workflow sets `HOST=0.0.0.0` and `PORT=8080` in Railway by default, unless the deploy job overrides `RAILWAY_RUNTIME_HOST` or `RAILWAY_RUNTIME_PORT`. +- Secret values are synchronized with Railway through `railway variable set KEY --stdin` so they are not exposed on the command line during GitHub Actions runs. +- Inline shell in the deployment and validation workflows is minimized in favor of reusable repo-root `just` recipes so the same deployment task entry points can be reused locally and in CI. +- The site deploy job continues to build the SwiftWASM app and sync `Output/` to S3 using a fixed `BYTESIZED_CAFE_API_URL`. +- The site deploy sync no longer owns generated images, because they live in a separate generated-images bucket from the static site bucket. + +## 11. Local Development +- A repo-root `justfile` provides the primary entry point for common local tasks such as `just wasm`, `just site`, `just site-local`, `just backend`, and `just local`, along with deployment-oriented recipes like `just site-release`, `just site-deploy`, and `just validate-deployment`. +- `Scripts/run-local.sh` provides a one-command local stack for development and opens the local site in the default browser after the backend and static site server are ready. +- The script rebuilds the `BytesizedCafe` SwiftWASM bundle, regenerates the site with `BYTESIZED_CAFE_API_URL` pointed at a localhost backend, prebuilds the backend to avoid counting SwiftPM compilation against the startup timeout, starts the Hummingbird server, and serves `Output/` over a local static HTTP server. +- The `justfile` and local-run script read a repo-root `.ENV` file by default when one exists, while still allowing direct shell environment configuration and host or port overrides for the site and backend. diff --git a/Scripts/build-bytesized-cafe-app.sh b/Scripts/build-bytesized-cafe-app.sh new file mode 100755 index 0000000..d822839 --- /dev/null +++ b/Scripts/build-bytesized-cafe-app.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BYTESIZED_CAFE_DIR="${ROOT_DIR}/BytesizedCafe" +OUTPUT_DIR="${ROOT_DIR}/bytesized-cafe-app" +PACKAGE_OUTPUT_DIR="${BYTESIZED_CAFE_DIR}/.build/plugins/PackageToJS/outputs/Package" +PRODUCT_NAME="BytesizedCafe" +SDK_LIST="$(swift sdk list)" +SWIFT_WASM_SDK_ID="${SWIFT_WASM_SDK_ID:-${SWIFT_SDK_ID:-}}" + +if [[ -z "${SWIFT_WASM_SDK_ID}" ]]; then + if grep -Fxq "wasm32-unknown-wasi" <<< "${SDK_LIST}"; then + SWIFT_WASM_SDK_ID="wasm32-unknown-wasi" + else + SWIFT_WASM_SDK_ID="$(grep 'wasm' <<< "${SDK_LIST}" | grep -v 'embedded' | head -n 1 || true)" + fi +fi + +if [[ -z "${SWIFT_WASM_SDK_ID}" ]] || ! grep -Fxq "${SWIFT_WASM_SDK_ID}" <<< "${SDK_LIST}"; then + echo "Swift SDK '${SWIFT_WASM_SDK_ID}' is not installed." >&2 + echo "Install a WebAssembly Swift SDK first: https://www.swift.org/documentation/articles/wasm-getting-started.html" >&2 + exit 1 +fi + +rm -rf "${OUTPUT_DIR}" +mkdir -p "${OUTPUT_DIR}" + +pushd "${BYTESIZED_CAFE_DIR}" >/dev/null +swift package --swift-sdk "${SWIFT_WASM_SDK_ID}" js --product "${PRODUCT_NAME}" -c release --use-cdn + +if [[ ! -d "${PACKAGE_OUTPUT_DIR}" ]]; then + echo "PackageToJS did not produce ${PRODUCT_NAME} artifacts." >&2 + exit 1 +fi + +cp -R "${PACKAGE_OUTPUT_DIR}/." "${OUTPUT_DIR}" +popd >/dev/null diff --git a/Scripts/run-local.sh b/Scripts/run-local.sh new file mode 100755 index 0000000..ccdeaba --- /dev/null +++ b/Scripts/run-local.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LOCAL_ENV_FILE="${LOCAL_ENV_FILE:-${ROOT_DIR}/.ENV}" +BACKEND_STARTUP_TIMEOUT=30 +SITE_STARTUP_TIMEOUT=10 + +backend_pid="" +site_server_pid="" + +require_command() { + local command_name="$1" + + if ! command -v "${command_name}" >/dev/null 2>&1; then + echo "Required command '${command_name}' is not installed or not on PATH." >&2 + exit 1 + fi +} + +load_local_env() { + if [[ ! -f "${LOCAL_ENV_FILE}" ]]; then + echo "No local env file found at ${LOCAL_ENV_FILE}; using the current shell environment." + return + fi + + echo "Loading ${LOCAL_ENV_FILE}" + set -a + # shellcheck disable=SC1090 + source "${LOCAL_ENV_FILE}" + set +a +} + +require_value() { + local variable_name="$1" + local value="$2" + + if [[ -n "${value}" ]]; then + return + fi + + echo "Required value '${variable_name}' is missing." >&2 + exit 1 +} + +cleanup() { + local exit_code=$? + + if [[ -n "${site_server_pid}" ]] && kill -0 "${site_server_pid}" >/dev/null 2>&1; then + kill "${site_server_pid}" >/dev/null 2>&1 || true + wait "${site_server_pid}" 2>/dev/null || true + fi + + if [[ -n "${backend_pid}" ]] && kill -0 "${backend_pid}" >/dev/null 2>&1; then + kill "${backend_pid}" >/dev/null 2>&1 || true + wait "${backend_pid}" 2>/dev/null || true + fi + + return "${exit_code}" +} + +build_backend() { + echo "Building the backend" + ( + cd "${ROOT_DIR}" + env "${backend_environment[@]}" swift build --package-path Backend --product Server + ) +} + +wait_for_backend() { + local attempt=1 + + while (( attempt <= BACKEND_STARTUP_TIMEOUT )); do + if curl -fsS "${health_url}" >/dev/null 2>&1; then + echo "Backend is healthy at ${health_url}" + return + fi + + if [[ -n "${backend_pid}" ]] && ! kill -0 "${backend_pid}" >/dev/null 2>&1; then + wait "${backend_pid}" + fi + + sleep 1 + ((attempt++)) + done + + echo "Backend did not become healthy at ${health_url} within ${BACKEND_STARTUP_TIMEOUT} seconds." >&2 + exit 1 +} + +wait_for_site() { + local attempt=1 + + while (( attempt <= SITE_STARTUP_TIMEOUT )); do + if curl -fsS "${site_url}" >/dev/null 2>&1; then + echo "Site is ready at ${site_url}" + return + fi + + if [[ -n "${site_server_pid}" ]] && ! kill -0 "${site_server_pid}" >/dev/null 2>&1; then + wait "${site_server_pid}" + fi + + sleep 1 + ((attempt++)) + done + + echo "Site did not become ready at ${site_url} within ${SITE_STARTUP_TIMEOUT} seconds." >&2 + exit 1 +} + +open_site_in_browser() { + if command -v open >/dev/null 2>&1; then + open "${site_url}" + return + fi + + if command -v xdg-open >/dev/null 2>&1; then + xdg-open "${site_url}" >/dev/null 2>&1 & + return + fi + + echo "No browser launcher command found; open ${site_url} manually." +} + +trap cleanup EXIT + +require_command curl +require_command swift +require_command python3 + +load_local_env + +site_host="${SITE_HOST:-}" +site_port="${SITE_PORT:-}" +backend_host="${BACKEND_HOST:-}" +backend_port="${BACKEND_PORT:-}" +generated_images_bucket="${GENERATED_IMAGES_BUCKET:-}" +openai_model="${OPENAI_IMAGE_MODEL:-}" +image_gen_prefix="${IMAGE_GEN_PREFIX:-}" +aws_region="${AWS_REGION:-}" +aws_access_key_id="${AWS_ACCESS_KEY_ID:-}" +aws_secret_access_key="${AWS_SECRET_ACCESS_KEY:-}" + +require_value "OPENAI_API_KEY" "${OPENAI_API_KEY:-}" +require_value "SITE_HOST" "${site_host}" +require_value "SITE_PORT" "${site_port}" +require_value "BACKEND_HOST" "${backend_host}" +require_value "BACKEND_PORT" "${backend_port}" +require_value "GENERATED_IMAGES_BUCKET" "${generated_images_bucket}" +require_value "OPENAI_IMAGE_MODEL" "${openai_model}" +require_value "IMAGE_GEN_PREFIX" "${image_gen_prefix}" +require_value "AWS_REGION" "${aws_region}" +require_value "AWS_ACCESS_KEY_ID" "${aws_access_key_id}" +require_value "AWS_SECRET_ACCESS_KEY" "${aws_secret_access_key}" + +health_url="http://${backend_host}:${backend_port}/health" +api_url="http://${backend_host}:${backend_port}/api/cafe/generate" +site_url="http://${site_host}:${site_port}" + +echo "Building the SwiftWASM app" +"${ROOT_DIR}/Scripts/build-bytesized-cafe-app.sh" + +echo "Publishing the site with API URL ${api_url}" +( + cd "${ROOT_DIR}" + env BYTESIZED_CAFE_API_URL="${api_url}" swift run bytesized +) + +backend_environment=( + "HOST=${backend_host}" + "PORT=${backend_port}" + "GENERATED_IMAGES_BUCKET=${generated_images_bucket}" + "OPENAI_API_KEY=${OPENAI_API_KEY}" + "OPENAI_IMAGE_MODEL=${openai_model}" + "IMAGE_GEN_PREFIX=${image_gen_prefix}" + "AWS_REGION=${aws_region}" + "AWS_ACCESS_KEY_ID=${aws_access_key_id}" + "AWS_SECRET_ACCESS_KEY=${aws_secret_access_key}" +) + +build_backend + +echo "Starting the backend on http://${backend_host}:${backend_port}" +( + cd "${ROOT_DIR}" + env "${backend_environment[@]}" swift run --skip-build --package-path Backend Server +) & +backend_pid=$! + +wait_for_backend + +echo "Serving Output at ${site_url}" +python3 -m http.server "${site_port}" --bind "${site_host}" --directory "${ROOT_DIR}/Output" & +site_server_pid=$! + +wait_for_site +echo "Opening ${site_url} in the default browser" +open_site_in_browser + +wait "${site_server_pid}" diff --git a/Scripts/sync-github-actions-config.sh b/Scripts/sync-github-actions-config.sh new file mode 100755 index 0000000..6869edf --- /dev/null +++ b/Scripts/sync-github-actions-config.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="${ROOT_DIR}/.ENV" +TARGET_REPOSITORY="" + +repository_variable_names=( + AWS_REGION + GENERATED_IMAGES_BUCKET + OPENAI_IMAGE_MODEL + IMAGE_GEN_PREFIX +) + +repository_secret_names=( + AWS_S3_BUCKET + OPENAI_API_KEY + AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY +) + +usage() { + cat <<'USAGE' +Usage: ./Scripts/sync-github-actions-config.sh [--env-file PATH] [--repo OWNER/REPO] + +Sync the GitHub Actions repository variables and secrets that overlap with the local .ENV file. +Values are streamed to `gh` over stdin so secrets do not appear in command arguments. +USAGE +} + +require_command() { + local command_name="$1" + + if ! command -v "${command_name}" >/dev/null 2>&1; then + echo "Required command '${command_name}' is not installed or not on PATH." >&2 + exit 1 + fi +} + +load_env_file() { + if [[ ! -f "${ENV_FILE}" ]]; then + echo "Local env file not found at ${ENV_FILE}." >&2 + exit 1 + fi + + set -a + # shellcheck disable=SC1090 + source "${ENV_FILE}" + set +a +} + +require_value() { + local variable_name="$1" + + if [[ -n "${!variable_name:-}" ]]; then + return + fi + + echo "Required value '${variable_name}' is missing from ${ENV_FILE}." >&2 + exit 1 +} + +sync_repository_variable() { + local variable_name="$1" + + require_value "${variable_name}" + printf '%s' "${!variable_name}" | run_gh variable set "${variable_name}" +} + +sync_repository_secret() { + local secret_name="$1" + + require_value "${secret_name}" + printf '%s' "${!secret_name}" | run_gh secret set "${secret_name}" +} + +run_gh() { + if [[ -n "${TARGET_REPOSITORY}" ]]; then + gh "$@" --repo "${TARGET_REPOSITORY}" + return + fi + + gh "$@" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --env-file) + ENV_FILE="$2" + shift 2 + ;; + --repo) + TARGET_REPOSITORY="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +require_command gh + +if ! gh auth status >/dev/null 2>&1; then + echo "GitHub CLI is not authenticated. Run 'gh auth login' first." >&2 + exit 1 +fi + +if ! run_gh repo view >/dev/null 2>&1; then + echo "GitHub CLI could not resolve the target repository. Run from a cloned repo or pass --repo OWNER/REPO." >&2 + exit 1 +fi + +load_env_file + +echo "Syncing GitHub Actions repository variables:" +for variable_name in "${repository_variable_names[@]}"; do + echo " - ${variable_name}" + sync_repository_variable "${variable_name}" +done + +echo "Syncing GitHub Actions repository secrets:" +for secret_name in "${repository_secret_names[@]}"; do + echo " - ${secret_name}" + sync_repository_secret "${secret_name}" +done + +cat <<'EOF' +Synchronized the overlapping GitHub Actions repository configuration from the local env file. +These deployment-only values still need to be managed separately because they are not part of the local .ENV: + - RAILWAY_PROJECT_ID + - RAILWAY_ENVIRONMENT_NAME + - RAILWAY_SERVICE_NAME + - RAILWAY_TOKEN + - BYTESIZED_CAFE_API_URL +EOF diff --git a/Scripts/sync-railway-backend-variables.sh b/Scripts/sync-railway-backend-variables.sh new file mode 100755 index 0000000..63231fc --- /dev/null +++ b/Scripts/sync-railway-backend-variables.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash + +set -euo pipefail + +required_environment_variables=( + AWS_ACCESS_KEY_ID + AWS_REGION + AWS_SECRET_ACCESS_KEY + GENERATED_IMAGES_BUCKET + IMAGE_GEN_PREFIX + OPENAI_API_KEY + OPENAI_IMAGE_MODEL + RAILWAY_ENVIRONMENT_NAME + RAILWAY_SERVICE_NAME + RAILWAY_TOKEN +) + +for variable_name in "${required_environment_variables[@]}"; do + if [[ -z "${!variable_name:-}" ]]; then + echo "Missing required environment variable '${variable_name}'." >&2 + exit 1 + fi +done + +backendHost="${RAILWAY_RUNTIME_HOST:-0.0.0.0}" +backendPort="${RAILWAY_RUNTIME_PORT:-8080}" + +set_railway_variable() { + npx -y @railway/cli variable set "$@" \ + --service "${RAILWAY_SERVICE_NAME}" \ + --environment "${RAILWAY_ENVIRONMENT_NAME}" \ + --skip-deploys \ + --yes +} + +set_railway_secret_from_stdin() { + local variable_name="$1" + local variable_value="$2" + + printf '%s' "${variable_value}" | npx -y @railway/cli variable set "${variable_name}" \ + --stdin \ + --service "${RAILWAY_SERVICE_NAME}" \ + --environment "${RAILWAY_ENVIRONMENT_NAME}" \ + --skip-deploys \ + --yes +} + +set_railway_variable \ + "HOST=${backendHost}" \ + "PORT=${backendPort}" \ + "GENERATED_IMAGES_BUCKET=${GENERATED_IMAGES_BUCKET}" \ + "OPENAI_IMAGE_MODEL=${OPENAI_IMAGE_MODEL}" \ + "IMAGE_GEN_PREFIX=${IMAGE_GEN_PREFIX}" \ + "AWS_REGION=${AWS_REGION}" + +set_railway_secret_from_stdin "OPENAI_API_KEY" "${OPENAI_API_KEY}" +set_railway_secret_from_stdin "AWS_ACCESS_KEY_ID" "${AWS_ACCESS_KEY_ID}" +set_railway_secret_from_stdin "AWS_SECRET_ACCESS_KEY" "${AWS_SECRET_ACCESS_KEY}" diff --git a/Scripts/validate-deployment-config.sh b/Scripts/validate-deployment-config.sh new file mode 100755 index 0000000..8b9abca --- /dev/null +++ b/Scripts/validate-deployment-config.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BACKEND_DIR="${ROOT_DIR}/Backend" +BACKEND_DOCKERFILE="${BACKEND_DIR}/Dockerfile" +WORKFLOW_DIR="${ROOT_DIR}/.github/workflows" + +require_command() { + local command_name="$1" + + if ! command -v "${command_name}" >/dev/null 2>&1; then + echo "Required command '${command_name}' is not installed or not on PATH." >&2 + exit 1 + fi +} + +require_command docker +require_command ruby + +if [[ ! -f "${BACKEND_DOCKERFILE}" ]]; then + echo "Expected backend Dockerfile at ${BACKEND_DOCKERFILE}." >&2 + exit 1 +fi + +if ! docker info >/dev/null 2>&1; then + echo "Docker is installed, but the Docker daemon is not running." >&2 + exit 1 +fi + +echo "Validating Railway backend container build..." +docker build --file "${BACKEND_DOCKERFILE}" "${BACKEND_DIR}" + +workflow_files=() +while IFS= read -r workflow_file; do + workflow_files+=("${workflow_file}") +done < <(find "${WORKFLOW_DIR}" -maxdepth 1 -type f \( -name "*.yml" -o -name "*.yaml" \) | sort) + +if [[ ${#workflow_files[@]} -eq 0 ]]; then + echo "No GitHub Actions workflow files were found in ${WORKFLOW_DIR}." >&2 + exit 1 +fi + +echo "Validating GitHub Actions workflow YAML..." +ruby -e ' +require "yaml" + +ARGV.each do |path| + YAML.load_file(path) +end + +puts "Workflow YAML is valid." +' "${workflow_files[@]}" + +if command -v actionlint >/dev/null 2>&1; then + echo "Running actionlint..." + actionlint +else + echo "actionlint is not installed; skipped semantic workflow validation." +fi diff --git a/Sources/bytesized/BytesizedCafe.swift b/Sources/bytesized/BytesizedCafe.swift new file mode 100644 index 0000000..4c4688f --- /dev/null +++ b/Sources/bytesized/BytesizedCafe.swift @@ -0,0 +1,68 @@ +import Foundation +import Plot + +enum BytesizedCafePageType: String { + case archive + case article + case index +} + +struct BytesizedCafeConfiguration { + let apiURL: String + + static var current: Self? { + guard + let apiURL = ProcessInfo.processInfo.environment["BYTESIZED_CAFE_API_URL"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !apiURL.isEmpty + else { + return nil + } + + return Self(apiURL: apiURL) + } +} + +extension Node where Context == HTML.AnchorContext { + static func bytesizedCafeMount( + pagePath: String, + pageType: BytesizedCafePageType, + configuration: BytesizedCafeConfiguration? + ) -> Node { + .div( + .id("bytesized-cafe-app"), + .class("bytesized-cafe-app"), + .attribute(named: "data-api-url", value: configuration?.apiURL ?? ""), + .attribute(named: "data-page-path", value: pagePath), + .attribute(named: "data-page-type", value: pageType.rawValue), + .attribute(named: "data-started", value: "ordered"), + .attribute(named: "data-state", value: "idle"), + .img( + .class("bytesized-cafe-image"), + .src("/images/preparing.png"), + .alt("Finding something local..."), + .attribute(named: "width", value: "160"), + .attribute(named: "height", value: "160") + ) + ) + } +} + +extension Node where Context == HTML.BodyContext { + static var bytesizedCafeScripts: Node { + guard BytesizedCafeConfiguration.current != nil else { + return .empty + } + + return .script( + .attribute(named: "type", value: "module"), + .raw( + """ + import { init } from "/bytesized-cafe-app/index.js"; + + void init(); + """ + ) + ) + } +} diff --git a/Sources/bytesized/CommonMark+Footnotes.swift b/Sources/bytesized/CommonMark+Footnotes.swift index e5d7a37..601ebeb 100644 --- a/Sources/bytesized/CommonMark+Footnotes.swift +++ b/Sources/bytesized/CommonMark+Footnotes.swift @@ -1,52 +1,65 @@ - import Foundation extension String { - + var formatter: DateFormatter { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" return formatter } - + func parseMarkdownFootnotes(date: Date) -> String { - // Match footnote references - func removeParagraphTags(_ string: String) -> String { - do { - let detagger = try NSRegularExpression(pattern: "(]+?>|

|<\\/p>)", options: []) - return detagger.stringByReplacingMatches(in: string, options: [], range: NSMakeRange(0, string.utf16.count), withTemplate: "") - } catch _ { - return string - } - } - - do { - var str = self - let olExpression = try NSRegularExpression(pattern: "\\[\\^\\d*]:.*", options: .dotMatchesLineSeparators) - - /// Remove stray

and

tags from our footnotes - let match = olExpression.firstMatch(in: str, options: [], range: NSMakeRange(0, str.utf16.count)) - if let range = match?.range { - let substring = NSString(string: str).substring(with: range) - str = str.replacingOccurrences(of: substring, with: removeParagraphTags(substring)) - } - - /// Add a divider and make the section an ordered list - let olTemplate = "


    $0
" - str = olExpression.stringByReplacingMatches(in: str, options: [], range: NSMakeRange(0, str.utf16.count), withTemplate: olTemplate) - - /// Turn references in the style `[^n]:` into links - let fnrExpression = try NSRegularExpression(pattern: "(\\[\\^(\\d*)]:)\\s(.*)$", options: .anchorsMatchLines) - let fnrTemplate = "
  • $3↩︎

  • " - str = fnrExpression.stringByReplacingMatches(in: str, options: [], range: NSMakeRange(0, str.utf16.count), withTemplate: fnrTemplate) - - /// Turn footnote references in the style `[^1]` into superscript links - let fnExpression = try NSRegularExpression(pattern: "(\\[\\^([\\d]+)\\])", options: []) - let fnTemplate = "$2" - str = fnExpression.stringByReplacingMatches(in: str, options: [], range: NSMakeRange(0, str.utf16.count), withTemplate: fnTemplate) - return str - } catch _ { - return self - } + // Match footnote references + func removeParagraphTags(_ string: String) -> String { + do { + let detagger = try NSRegularExpression( + pattern: "(]+?>|

    |<\\/p>)", options: []) + return detagger.stringByReplacingMatches( + in: string, options: [], range: NSMakeRange(0, string.utf16.count), + withTemplate: "") + } catch _ { + return string + } + } + + do { + var str = self + let olExpression = try NSRegularExpression( + pattern: "\\[\\^\\d*]:.*", options: .dotMatchesLineSeparators) + + /// Remove stray

    and

    tags from our footnotes + let match = olExpression.firstMatch( + in: str, options: [], range: NSMakeRange(0, str.utf16.count)) + if let range = match?.range { + let substring = NSString(string: str).substring(with: range) + str = str.replacingOccurrences(of: substring, with: removeParagraphTags(substring)) + } + + /// Add a divider and make the section an ordered list + let olTemplate = "


      $0
    " + str = olExpression.stringByReplacingMatches( + in: str, options: [], range: NSMakeRange(0, str.utf16.count), + withTemplate: olTemplate) + + /// Turn references in the style `[^n]:` into links + let fnrExpression = try NSRegularExpression( + pattern: "(\\[\\^(\\d*)]:)\\s(.*)$", options: .anchorsMatchLines) + let fnrTemplate = + "
  • $3↩︎

  • " + str = fnrExpression.stringByReplacingMatches( + in: str, options: [], range: NSMakeRange(0, str.utf16.count), + withTemplate: fnrTemplate) + + /// Turn footnote references in the style `[^1]` into superscript links + let fnExpression = try NSRegularExpression(pattern: "(\\[\\^([\\d]+)\\])", options: []) + let fnTemplate = + "$2" + str = fnExpression.stringByReplacingMatches( + in: str, options: [], range: NSMakeRange(0, str.utf16.count), + withTemplate: fnTemplate) + return str + } catch _ { + return self + } } } diff --git a/Sources/bytesized/CommonMark+Metadata.swift b/Sources/bytesized/CommonMark+Metadata.swift index 69a55a9..7499a14 100644 --- a/Sources/bytesized/CommonMark+Metadata.swift +++ b/Sources/bytesized/CommonMark+Metadata.swift @@ -1,11 +1,11 @@ - import Foundation extension String { func stripMetadata() -> String { do { let metadata = try NSRegularExpression(pattern: #"---([\s\S]*?)---"#, options: []) - let match = metadata.firstMatch(in: self, options: [], range: NSRange(location: 0, length: utf16.count)) + let match = metadata.firstMatch( + in: self, options: [], range: NSRange(location: 0, length: utf16.count)) if let range = match?.range { let substring = NSString(string: self).substring(with: range) return self.replacingOccurrences(of: substring, with: "") diff --git a/Sources/bytesized/Item+CommonMark.swift b/Sources/bytesized/Item+CommonMark.swift index 3a21fd1..f48cb9a 100644 --- a/Sources/bytesized/Item+CommonMark.swift +++ b/Sources/bytesized/Item+CommonMark.swift @@ -1,7 +1,6 @@ - +import CommonMark import Foundation import Publish -import CommonMark import Splash extension Item { @@ -32,7 +31,7 @@ func applySyntaxHighlighting(to html: String) -> String { pattern: pattern, options: [.dotMatchesLineSeparators] ) - + // Create a mutable copy to perform in-place replacements. let mutableHTML = NSMutableString(string: html) let matches = regex.matches( @@ -40,33 +39,34 @@ func applySyntaxHighlighting(to html: String) -> String { options: [], range: NSRange(location: 0, length: html.utf16.count) ) - + // Initialize your syntax highlighter. let highlighter = SyntaxHighlighter(format: HTMLOutputFormat(classPrefix: "splash-")) - + // Process matches in reverse order so earlier ranges remain valid. for match in matches.reversed() { // Group 1: language attribute (if any); Group 2: code content. let languageRange = match.range(at: 1) let codeRange = match.range(at: 2) guard codeRange.location != NSNotFound, - let swiftCodeRange = Range(codeRange, in: html) else { + let swiftCodeRange = Range(codeRange, in: html) + else { continue } - + let codeContent = String(html[swiftCodeRange]).decodedHTMLEntities // Only highlight if the language is explicitly Swift. let isSwiftBlock = languageRange.location != NSNotFound let processedCode = isSwiftBlock ? highlighter.highlight(codeContent) : codeContent - + // Recreate the code block with proper class if it was a Swift block. let classAttribute = isSwiftBlock ? " class=\"language-swift\"" : "" let replacementHTML = "
    \(processedCode)
    " - + // Replace the entire match in the mutable HTML. mutableHTML.replaceCharacters(in: match.range, with: replacementHTML) } - + return mutableHTML as String } catch { print("Error applying syntax highlighting: \(error.localizedDescription)") @@ -81,11 +81,10 @@ extension String { "<": "<", ">": ">", """: "\"", - "&": "&" + "&": "&", ] return entities.reduce(self) { result, pair in result.replacingOccurrences(of: pair.key, with: pair.value) } } } - diff --git a/Sources/bytesized/Theme+Bytesized.swift b/Sources/bytesized/Theme+Bytesized.swift index 580060b..35e4a67 100644 --- a/Sources/bytesized/Theme+Bytesized.swift +++ b/Sources/bytesized/Theme+Bytesized.swift @@ -1,12 +1,11 @@ - import Foundation import Plot import Publish private let dateFormat = "MM.dd.yyyy" -public extension Theme { - static var bytesized: Self { +extension Theme { + public static var bytesized: Self { Theme( htmlFactory: BytesizedHTMLFactory(), resourcePaths: [] @@ -15,99 +14,110 @@ public extension Theme { } private struct BytesizedHTMLFactory: HTMLFactory { - + private var dateFormatter: DateFormatter { let formatter = DateFormatter() formatter.dateFormat = dateFormat return formatter } - - func makeIndexHTML(for index: Index, - context: PublishingContext) throws -> HTML { + + func makeIndexHTML( + for index: Index, + context: PublishingContext + ) throws -> HTML { HTML( .lang(context.site.language), .head(site: context.site, location: index), .body( - .div(.class("pure-g"), - .div(.class("pure-u-0-4 pure-u-md-1-12 pure-u-lg-1-4")), - .div(.class("pure-u-1-1 pure-u-md-5-6 pure-u-lg-1-2"), - .header(for: context), + .div( + .class("pure-g"), + .div(.class("pure-u-0-4 pure-u-md-1-12 pure-u-lg-1-4")), + .div( + .class("pure-u-1-1 pure-u-md-5-6 pure-u-lg-1-2"), + .header(for: context, pagePath: "/", pageType: .index), .itemList(for: context.allItems.chunked(into: 5).first ?? []), .paginator(currentPage: 0, context: context) ), .div(.class("pure-u-0-4 pure-u-md-1-12 pure-u-lg-1-4")) ), - .footer(for: context.site) + .footer(for: context.site), + .bytesizedCafeScripts ) ) } - func makeItemHTML(for item: Item, - context: PublishingContext) throws -> HTML { + func makeItemHTML( + for item: Item, + context: PublishingContext + ) throws -> HTML { HTML( .lang(context.site.language), .head(site: context.site, location: item), .body( - .div(.class("pure-g"), - .div(.class("pure-u-0-4 pure-u-md-1-12 pure-u-lg-1-4")), - .div(.class("pure-u-1-1 pure-u-md-5-6 pure-u-lg-1-2"), - .header(for: context), - .title(item: item.bytesized), - .date(item: item.bytesized), - .div(.class("content"), item.content.body.node) - ), - .div(.class("pure-u-0-4 pure-u-md-1-12 pure-u-lg-1-4")) + .div( + .class("pure-g"), + .div(.class("pure-u-0-4 pure-u-md-1-12 pure-u-lg-1-4")), + .div( + .class("pure-u-1-1 pure-u-md-5-6 pure-u-lg-1-2"), + .header( + for: context, + pagePath: item.path.absoluteString, + pageType: .article + ), + .title(item: item.bytesized), + .date(item: item.bytesized), + .div(.class("content"), item.content.body.node) + ), + .div(.class("pure-u-0-4 pure-u-md-1-12 pure-u-lg-1-4")) ), - .footer(for: context.site) + .footer(for: context.site), + .bytesizedCafeScripts ) ) } // Paginated - func makePageHTML(for page: Page, - context: PublishingContext) throws -> HTML { + func makePageHTML( + for page: Page, + context: PublishingContext + ) throws -> HTML { HTML( .lang(context.site.language), .head(site: context.site, location: page), .body(page.content.body.node) ) } - - func makeSectionHTML(for section: Section, - context: PublishingContext) throws -> HTML { + + func makeSectionHTML( + for section: Section, + context: PublishingContext + ) throws -> HTML { HTML() } - + func makeTagListHTML(for page: TagListPage, context: PublishingContext) throws -> HTML? { return nil } - - func makeTagDetailsHTML(for page: TagDetailsPage, context: PublishingContext) throws -> HTML? { + + func makeTagDetailsHTML(for page: TagDetailsPage, context: PublishingContext) throws + -> HTML? + { return nil } } -private extension Node where Context == HTML.DocumentContext { - static func head(site: T, location: Location) -> Node { +extension Node where Context == HTML.DocumentContext { + fileprivate static func head(site: T, location: Location) -> Node { return .head( .meta(.charset(.utf8)), .meta(.name("viewport"), .content("width=device-width, initial-scale=1")), .meta(.name("description"), .content(site.description)), .title(location.title), - .script( - .text( - """ - function randomImg(){ - var num = Math.ceil( Math.random() * 31 ); - return '/images/logo/'+num+'.png'; - }; - """ - ) - ), .link(.rel(.stylesheet), .href("/css/styles.css"), .type("text/css")), .link(.rel(.stylesheet), .href("/css/normalized.css"), .type("text/css")), .link(.rel(.stylesheet), .href("/css/pure/pure-min.css"), .type("text/css")), - .link(.rel(.stylesheet), .href("/css/pure/grids-responsive-min.css"), .type("text/css")), + .link( + .rel(.stylesheet), .href("/css/pure/grids-responsive-min.css"), .type("text/css")), .link(.rel(.icon), .href("/images/favico.ico"), .sizes("32x32")), .link(.rel(.appleTouchIcon), .href("/images/ico.png")), .link(.rel(.alternate), .href("/feed.rss"), .type("application/rss+xml")) @@ -119,20 +129,27 @@ extension PublishingContext { var allItems: [Item] { sections.flatMap { $0.items } } - + func pageContent(for page: Int, items: [Item]) -> Content { let body = HTML( .body( - .div(.class("pure-g"), - .div(.class("pure-u-0-4 pure-u-md-1-12 pure-u-lg-1-4")), - .div(.class("pure-u-1-1 pure-u-md-5-6 pure-u-lg-1-2"), - .header(for: self), - .itemList(for: items), - .paginator(currentPage: page, context: self) - ), - .div(.class("pure-u-0-4 pure-u-md-1-12 pure-u-lg-1-4")) + .div( + .class("pure-g"), + .div(.class("pure-u-0-4 pure-u-md-1-12 pure-u-lg-1-4")), + .div( + .class("pure-u-1-1 pure-u-md-5-6 pure-u-lg-1-2"), + .header( + for: self, + pagePath: "/page/\(page)", + pageType: .archive + ), + .itemList(for: items), + .paginator(currentPage: page, context: self) + ), + .div(.class("pure-u-0-4 pure-u-md-1-12 pure-u-lg-1-4")) ), - .footer(for: site) + .footer(for: site), + .bytesizedCafeScripts ) ) @@ -140,68 +157,80 @@ extension PublishingContext { } } -private extension Node where Context == HTML.BodyContext { - static var dateFormatter: DateFormatter { +extension Node where Context == HTML.BodyContext { + fileprivate static var dateFormatter: DateFormatter { let formatter = DateFormatter() formatter.dateFormat = dateFormat return formatter } - - static func header(for context: PublishingContext) -> Node { + + fileprivate static func header( + for context: PublishingContext, + pagePath: String, + pageType: BytesizedCafePageType + ) -> Node { + let configuration = BytesizedCafeConfiguration.current + return .header( .class("header"), .a( .class("site-name"), .href("/"), - .img( - .src("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg=="), - .attribute(named: "onload", value: "this.onload=null; this.src=randomImg();"), - .attribute(named: "height", value: "50%"), - .attribute(named: "width", value: "50%") + .bytesizedCafeMount( + pagePath: pagePath, + pageType: pageType, + configuration: configuration ) ) ) } - - static func itemList(for items: [Item]) -> Node { + + fileprivate static func itemList(for items: [Item]) -> Node { return .forEach(items) { item in .group([ - .div(.class("title"), .a(.href(item.path.absoluteString), .text(item.bytesized.metadata.title))), - .div(.class("date"), .text(dateFormatter.string(from: item.bytesized.metadata.date))), - .div(.class("content"), item.content.body.node) + .div( + .class("title"), + .a(.href(item.path.absoluteString), .text(item.bytesized.metadata.title)) + ), + .div( + .class("date"), .text(dateFormatter.string(from: item.bytesized.metadata.date))), + .div(.class("content"), item.content.body.node), ]) } } - - static func title(item: Item) -> Node { + + fileprivate static func title(item: Item) -> Node { .div(.class("title"), .a(.href(item.path.absoluteString), .text(item.metadata.title))) } - - static func date(item: Item) -> Node { + + fileprivate static func date(item: Item) -> Node { .div(.class("date"), .text(dateFormatter.string(from: item.metadata.date))) } - static func footer(for site: T) -> Node { + fileprivate static func footer(for site: T) -> Node { .footer(.class("footer"), .div(.style("text-align:center"), .text(site.footer))) } - static func paginator(currentPage: Int, context: PublishingContext) -> Node { + fileprivate static func paginator(currentPage: Int, context: PublishingContext) + -> Node + { func next(path: String) -> Node { .div(.style("float: right"), .a(.href(path), .class("next"), .text("more →"))) } - + func previous(path: String) -> Node { .a(.href(path), .class("previous"), .text("← previous")) } - + let nextPage = currentPage + 1 let previousPage = currentPage - 1 let previousLink = previousPage == 0 ? "/" : "/page/\(previousPage)" let nextLink = "/page/\(nextPage)" let isNext = context.allItems.count > nextPage * 5 let isPrevious = currentPage != 0 - - return .div(.class("footer"), .style("min-height: 30px"), + + return .div( + .class("footer"), .style("min-height: 30px"), .if(isPrevious, previous(path: previousLink)), .if(isNext, next(path: nextLink)) ) @@ -211,7 +240,7 @@ private extension Node where Context == HTML.BodyContext { extension Array { func chunked(into size: Int) -> [[Element]] { return stride(from: 0, to: count, by: size).map { - Array(self[$0 ..< Swift.min($0 + size, count)]) + Array(self[$0..], + file: StaticString = #filePath + ) async throws -> PublishedWebsite { + let publish: + (Path?, [PublishingStep], StaticString) async throws + -> PublishedWebsite = self.publish + return try await publish(path, steps, file) + } +} + +func parseBytesizedContent(_ text: String, metadata: Bytesized.ItemMetadata) -> Content { + Content(body: Content.Body(stringLiteral: commonMarkBody(text, metadata: metadata))) } -try Bytesized().publish(using: [ +func applyBytesizedMetadata(_ item: inout Item) { + item.content.title = item.metadata.title + item.content.date = item.metadata.date +} + +func copyBytesizedCafeApp(using context: PublishingContext) throws { + let fileManager = FileManager.default + let sourceFolder = try context.folder(at: Path("bytesized-cafe-app")) + let destinationFolder = try context.createOutputFolder(at: Path("bytesized-cafe-app")) + try destinationFolder.delete() + try fileManager.copyItem(at: sourceFolder.url, to: destinationFolder.url) +} + +_ = try await Bytesized().publishAsync(using: [ .step(named: "Custom Date Formatter") { context in let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" context.dateFormatter = formatter }, - .copyResources(), - .addMarkdownFiles(customContentParser: { text, metadata -> Content in - Content(body: Content.Body(stringLiteral: commonMarkBody(text, metadata: metadata))) - }), + .copyResources( + at: Path("Resources/images"), to: Path("images"), includingFolder: false), + .copyFile(at: Path("Resources/css/normalized.css"), to: Path("css")), + .copyFile(at: Path("Resources/css/styles.css"), to: Path("css")), + .copyFile(at: Path("Resources/css/pure/pure-min.css"), to: Path("css/pure")), + .copyFile(at: Path("Resources/css/pure/grids-responsive-min.css"), to: Path("css/pure")), + .copyResources(at: Path("Resources/fonts"), to: Path("fonts"), includingFolder: false), + .addMarkdownFiles(customContentParser: parseBytesizedContent), .step(named: "Name Index") { context in context.index.title = context.site.name }, - .mutateAllItems { item in - item.content.title = item.metadata.title - item.content.date = item.metadata.date - }, + .mutateAllItems(using: applyBytesizedMetadata), .sortItems(by: \.metadata.date, order: .descending), .step(named: "Paginate") { context in for section in context.sections.ids { @@ -60,28 +89,35 @@ try Bytesized().publish(using: [ if i == 0 { continue } - let page = Page(path: "/page/\(i)/index.html", content: context.pageContent(for: i, items: chunk)) + let page = Page( + path: "/page/\(i)/index.html", + content: context.pageContent(for: i, items: chunk)) context.addPage(page) } } }, .generateHTML(withTheme: .bytesized, fileMode: .standAloneFilesClean), .generateRSSFeed(including: [.posts]), - .deploy(using: .s3("bytesized.co")) + .step(named: "Copy Bytesized Cafe app") { context in + try copyBytesizedCafeApp(using: context) + }, + .deploy(using: .s3("bytesized.co")), ]) -public extension DeploymentMethod { +extension DeploymentMethod { // Requires AWS CLI to be installed - static func s3(_ bucket: String) -> Self { + public static func s3(_ bucket: String) -> Self { DeploymentMethod(name: "S3 (\(bucket))") { context in let s3 = try context.createDeploymentFolder(withPrefix: "s3_", configure: { _ in }) // HTML try shellOut( - to: "aws s3 sync \(s3.path) s3://\(bucket) --exclude '*' --exclude '*.DS_Store' --include 'posts/*' --include 'page/*' --include 'index.html' --content-type 'text/html'", + to: + "aws s3 sync \(s3.path) s3://\(bucket) --exclude '*' --exclude '*.DS_Store' --include 'posts/*' --include 'page/*' --include 'index.html' --content-type 'text/html'", outputHandle: FileHandle.standardOutput) // Resources try shellOut( - to: "aws s3 sync \(s3.path) s3://\(bucket) --include '*' --exclude 'posts/*' --exclude 'page/*' --exclude 'index.html' --exclude '*.DS_Store'", + to: + "aws s3 sync \(s3.path) s3://\(bucket) --include '*' --exclude 'posts/*' --exclude 'page/*' --exclude 'index.html' --exclude '*.DS_Store'", outputHandle: FileHandle.standardOutput) } } diff --git a/justfile b/justfile new file mode 100644 index 0000000..2d77692 --- /dev/null +++ b/justfile @@ -0,0 +1,40 @@ +set dotenv-load := true +set dotenv-filename := ".ENV" + +help: + @just --list --unsorted + +wasm: + ./Scripts/build-bytesized-cafe-app.sh + +site: + swift run bytesized + +site-release: + swift run -c release bytesized + +site-local: + test -n "${BYTESIZED_CAFE_API_URL:-}" || (echo "Missing BYTESIZED_CAFE_API_URL. Set it in .ENV or your shell." >&2; exit 1) + BYTESIZED_CAFE_API_URL="${BYTESIZED_CAFE_API_URL}" swift run bytesized + +backend: + test -n "${OPENAI_API_KEY:-}" || (echo "Missing OPENAI_API_KEY. Set it in .ENV or your shell." >&2; exit 1) + test -n "${GENERATED_IMAGES_BUCKET:-}" || (echo "Missing GENERATED_IMAGES_BUCKET. Set it in .ENV or your shell." >&2; exit 1) + test -n "${OPENAI_IMAGE_MODEL:-}" || (echo "Missing OPENAI_IMAGE_MODEL. Set it in .ENV or your shell." >&2; exit 1) + test -n "${IMAGE_GEN_PREFIX:-}" || (echo "Missing IMAGE_GEN_PREFIX. Set it in .ENV or your shell." >&2; exit 1) + test -n "${AWS_REGION:-}" || (echo "Missing AWS_REGION. Set it in .ENV or your shell." >&2; exit 1) + test -n "${AWS_ACCESS_KEY_ID:-}" || (echo "Missing AWS_ACCESS_KEY_ID. Set it in .ENV or your shell." >&2; exit 1) + test -n "${AWS_SECRET_ACCESS_KEY:-}" || (echo "Missing AWS_SECRET_ACCESS_KEY. Set it in .ENV or your shell." >&2; exit 1) + test -n "${HOST:-${BACKEND_HOST:-}}" || (echo "Missing HOST or BACKEND_HOST. Set it in .ENV or your shell." >&2; exit 1) + test -n "${PORT:-${BACKEND_PORT:-}}" || (echo "Missing PORT or BACKEND_PORT. Set it in .ENV or your shell." >&2; exit 1) + swift run --package-path Backend Server + +site-deploy: + test -n "${AWS_S3_BUCKET:-}" || (echo "Missing AWS_S3_BUCKET. Set it in .ENV or your shell." >&2; exit 1) + aws s3 sync Output/ "s3://${AWS_S3_BUCKET}" --delete --exclude ".DS_Store" + +local: + ./Scripts/run-local.sh + +validate-deployment: + ./Scripts/validate-deployment-config.sh diff --git a/skills/sosumi/SKILL.md b/skills/sosumi/SKILL.md new file mode 100644 index 0000000..7003cc2 --- /dev/null +++ b/skills/sosumi/SKILL.md @@ -0,0 +1,91 @@ +--- +name: sosumi +description: Fetches Apple documentation as Markdown via Sosumi. Use for Apple API reference, Human Interface Guidelines, WWDC transcripts, and external Swift-DocC pages. +--- + +# Sosumi Skill + +Use this skill to reliably fetch Apple docs as Markdown when coding agents need precise API details. + +## When to Use + +Use Sosumi when the request involves any of the following: + +- Apple platform APIs (`Swift`, `SwiftUI`, `UIKit`, `AppKit`, `Foundation`, etc.) +- API signatures, availability, parameter behavior, or return semantics +- Human Interface Guidelines questions +- WWDC session transcript lookup +- External Swift-DocC documentation (for example, GitHub Pages or Swift Package Index hosts) + +## Core Workflow + +1. If you already have a `developer.apple.com` URL, replace the host with `sosumi.ai` and keep the same path. +2. If you do not know the exact page path, search first, then fetch the best match. +3. Prefer specific symbol pages instead of broad top-level pages when answering implementation questions. + +## HTTP Usage + +Replace `developer.apple.com` with `sosumi.ai`: + +- Original: `https://developer.apple.com/documentation/swift/array` +- AI-readable: `https://sosumi.ai/documentation/swift/array` + +## Content Types + +### Apple API Reference + +- Pattern: `https://sosumi.ai/documentation/{framework}/{symbol}` +- Examples: + - `https://sosumi.ai/documentation/swift/array` + - `https://sosumi.ai/documentation/swiftui/view` + +### Human Interface Guidelines + +- Pattern: `https://sosumi.ai/design/human-interface-guidelines/{topic}` +- Examples: + - `https://sosumi.ai/design/human-interface-guidelines` + - `https://sosumi.ai/design/human-interface-guidelines/foundations/color` + +### Apple Video Transcripts + +- Pattern: `https://sosumi.ai/videos/play/{collection}/{id}` +- Examples: + - `https://sosumi.ai/videos/play/wwdc2021/10133` + - `https://sosumi.ai/videos/play/meet-with-apple/208` + +### External Swift-DocC + +- Pattern: `https://sosumi.ai/external/{full-https-url}` +- Examples: + - `https://sosumi.ai/external/https://apple.github.io/swift-argument-parser/documentation/argumentparser/` + - `https://sosumi.ai/external/https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/1.23.1/documentation/composablearchitecture` + +## MCP Tools Quick Reference + +Use these when Sosumi is configured as an MCP server (`https://sosumi.ai/mcp`): + +| Tool | Parameters | Use | +|---|---|---| +| `searchAppleDocumentation` | `query: string` | Search Apple documentation and return structured results | +| `fetchAppleDocumentation` | `path: string` | Fetch Apple docs or HIG content by path as Markdown | +| `fetchAppleVideoTranscript` | `path: string` | Fetch Apple video transcript by `/videos/play/...` path | +| `fetchExternalDocumentation` | `url: string` | Fetch external Swift-DocC page by absolute HTTPS URL | + +## Best Practices + +- Search first if the exact path is unknown. +- Fetch targeted symbol pages for coding questions. +- Keep source links in answers so users can verify details quickly. +- Use Sosumi paths directly in responses whenever referencing Apple documentation pages. + +## Troubleshooting + +### 404 or sparse output + +- The path may be incorrect or too broad. +- Run a search query first, then fetch a specific result path. + +### External page cannot be fetched + +- The host may block access via `robots.txt` or `X-Robots-Tag` directives. +- Try another canonical page URL for the same symbol. diff --git a/skills/swift-concurrency/SKILL.md b/skills/swift-concurrency/SKILL.md new file mode 100644 index 0000000..44269a9 --- /dev/null +++ b/skills/swift-concurrency/SKILL.md @@ -0,0 +1,257 @@ +--- +name: swift-concurrency +description: Expert guidance on Swift Concurrency concepts. Use when working with async/await, Tasks, actors, MainActor, Sendable, isolation domains, or debugging concurrency compiler errors. Helps write safe concurrent Swift code. +--- + +# Swift Concurrency Skill + +This skill provides expert guidance on Swift's concurrency system based on the mental models from [Fucking Approachable Swift Concurrency](https://fuckingapproachableswiftconcurrency.com). + +## Core Mental Model: The Office Building + +Think of your app as an office building where **isolation domains** are private offices with locks: + +- **MainActor** = Front desk (handles all UI interactions, only one exists) +- **actor** types = Department offices (Accounting, Legal, HR - each protects its own data) +- **nonisolated** code = Hallways (shared space, no private documents) +- **Sendable** types = Photocopies (safe to share between offices) +- **Non-Sendable** types = Original documents (must stay in one office) + +You can't barge into someone's office. You knock (`await`) and wait. + +## Async/Await + +An `async` function can pause. Use `await` to suspend until work finishes: + +```swift +func fetchUser(id: Int) async throws -> User { + let (data, _) = try await URLSession.shared.data(from: url) + return try JSONDecoder().decode(User.self, from: data) +} +``` + +For parallel work, use `async let`: + +```swift +async let avatar = fetchImage("avatar.jpg") +async let banner = fetchImage("banner.jpg") +return Profile(avatar: try await avatar, banner: try await banner) +``` + +## Tasks + +A `Task` is a unit of async work you can manage: + +```swift +// SwiftUI - cancels when view disappears +.task { avatar = await downloadAvatar() } + +// Manual task creation +Task { await saveProfile() } + +// Parallel work with TaskGroup +try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { avatar = try await downloadAvatar() } + group.addTask { bio = try await fetchBio() } + try await group.waitForAll() +} +``` + +Child tasks in a group: cancellation propagates, errors cancel siblings, waits for all to complete. + +## Isolation Domains + +Swift asks "who can access this data?" not "which thread?". Three isolation domains: + +### 1. MainActor + +For UI. Everything UI-related should be here: + +```swift +@MainActor +class ViewModel { + var items: [Item] = [] // Protected by MainActor +} +``` + +### 2. Actors + +Protect their own mutable state with exclusive access: + +```swift +actor BankAccount { + var balance: Double = 0 + func deposit(_ amount: Double) { balance += amount } +} + +await account.deposit(100) // Must await from outside +``` + +### 3. Nonisolated + +Opts out of actor isolation. Cannot access actor's protected state: + +```swift +actor BankAccount { + nonisolated func bankName() -> String { "Acme Bank" } +} +let name = account.bankName() // No await needed +``` + +## Approachable Concurrency (Swift 6.2+) + +Two build settings that simplify the mental model: + +- **SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor**: Everything runs on MainActor unless you say otherwise +- **SWIFT_APPROACHABLE_CONCURRENCY = YES**: nonisolated async functions stay on caller's actor + +```swift +// Runs on MainActor (default) +func updateUI() async { } + +// Runs on background (opt-in) +@concurrent func processLargeFile() async { } +``` + +## Sendable + +Marks types safe to pass across isolation boundaries: + +```swift +// Sendable - value type, each gets a copy +struct User: Sendable { + let id: Int + let name: String +} + +// Non-Sendable - mutable class state +class Counter { + var count = 0 +} +``` + +Automatically Sendable: +- Structs/enums with only Sendable properties +- Actors (protect their own state) +- @MainActor types (MainActor serializes access) + +For thread-safe classes with internal synchronization: + +```swift +final class ThreadSafeCache: @unchecked Sendable { + private let lock = NSLock() + private var storage: [String: Data] = [:] +} +``` + +## Isolation Inheritance + +With Approachable Concurrency, isolation flows from MainActor through your code: + +- **Functions**: Inherit caller's isolation unless explicitly marked +- **Closures**: Inherit from context where defined +- **Task { }**: Inherits actor isolation from creation site +- **Task.detached { }**: No inheritance (rarely needed) + +### Preserving Isolation in Async Utilities + +When writing generic async functions that accept closures, you need to preserve the caller's isolation to avoid Sendable errors. + +**Option 1: `nonisolated(nonsending)`** (simpler) + +```swift +// Stays on caller's executor, no Sendable needed +nonisolated(nonsending) +func measure(_ label: String, block: () async throws -> T) async rethrows -> T +``` + +**Option 2: `#isolation` parameter** (when you need actor access) + +```swift +// Explicit isolation parameter, useful if you need to pass it around +func measure( + isolation: isolated (any Actor)? = #isolation, + _ label: String, + block: () async throws -> T +) async rethrows -> T +``` + +Use `nonisolated(nonsending)` by default. Use `#isolation` when you need explicit access to the actor instance. + +## Common Mistakes to Avoid + +### 1. Thinking async = background + +```swift +// Still blocks main thread! +@MainActor func slowFunction() async { + let result = expensiveCalculation() // Synchronous = blocking +} +// Fix: Use @concurrent for CPU-heavy work +``` + +### 2. Creating too many actors + +Most things can live on MainActor. Only create actors when you have shared mutable state that can't be on MainActor. + +### 3. Making everything Sendable + +Not everything needs to cross boundaries. Step back and ask if data actually moves between isolation domains. + +### 4. Using MainActor.run unnecessarily + +```swift +// Unnecessary +await MainActor.run { self.data = data } + +// Better - annotate the function +@MainActor func loadData() async { self.data = await fetchData() } +``` + +### 5. Blocking the cooperative thread pool + +Never use DispatchSemaphore, DispatchGroup.wait() in async code. Risks deadlock. + +### 6. Creating unnecessary Tasks + +```swift +// Bad - unstructured +Task { await fetchUsers() } +Task { await fetchPosts() } + +// Good - structured concurrency +async let users = fetchUsers() +async let posts = fetchPosts() +await (users, posts) +``` + +## Quick Reference + +| Keyword | Purpose | +|---------|---------| +| `async` | Function can pause | +| `await` | Pause here until done | +| `Task { }` | Start async work, inherits context | +| `Task.detached { }` | Start async work, no context | +| `@MainActor` | Runs on main thread | +| `actor` | Type with isolated mutable state | +| `nonisolated` | Opts out of actor isolation | +| `nonisolated(nonsending)` | Stay on caller's executor | +| `Sendable` | Safe to pass between isolation domains | +| `@concurrent` | Always run on background (Swift 6.2+) | +| `#isolation` | Capture caller's isolation as parameter | +| `async let` | Start parallel work | +| `TaskGroup` | Dynamic parallel work | + +## When the Compiler Complains + +Trace the isolation: Where did it come from? Where is code trying to run? What data crosses a boundary? + +The answer is usually obvious once you ask the right question. + +## Further Reading + +- [Matt Massicotte's Blog](https://www.massicotte.org/) - The source of these mental models +- [Swift Concurrency Documentation](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/) +- [WWDC21: Meet async/await](https://developer.apple.com/videos/play/wwdc2021/10132/) +- [WWDC21: Protect mutable state with actors](https://developer.apple.com/videos/play/wwdc2021/10133/) diff --git a/skills/swift-format/SKILL.md b/skills/swift-format/SKILL.md new file mode 100644 index 0000000..9e60ac1 --- /dev/null +++ b/skills/swift-format/SKILL.md @@ -0,0 +1,30 @@ +--- +name: swift-format +description: Format Swift code in place with `swift-format format PATH --recursive --parallel -i`. Use when asked to format Swift projects or Swift source trees before review, testing, or commit. +--- + +# Swift Format + +## Overview + +Run the repository's Swift formatter command in a consistent way. +Use the bundled script to apply formatting recursively and in place. + +## Run Formatter + +1. Run the helper script from the target repository root: + `./skills/swift-format/scripts/run-swift-format.sh` +2. Pass a path to scope formatting when needed: + `./skills/swift-format/scripts/run-swift-format.sh Sources` + +## Script Behavior + +- Execute: + `swift-format format --recursive --parallel -i` +- Default `` to `.` when omitted. +- Exit with a clear error if `swift-format` is unavailable. + +## Validate + +Run: +`python3 /Users/pvzig/.codex/skills/.system/skill-creator/scripts/quick_validate.py skills/swift-format` diff --git a/skills/swift-format/agents/openai.yaml b/skills/swift-format/agents/openai.yaml new file mode 100644 index 0000000..257fe5c --- /dev/null +++ b/skills/swift-format/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Swift Format" + short_description: "Run swift-format recursively in place" + default_prompt: "Run swift-format on this Swift project" diff --git a/skills/swift-format/scripts/run-swift-format.sh b/skills/swift-format/scripts/run-swift-format.sh new file mode 100755 index 0000000..019876c --- /dev/null +++ b/skills/swift-format/scripts/run-swift-format.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +target_path="${1:-.}" + +if ! command -v swift-format >/dev/null 2>&1; then + echo "Error: swift-format is not available on PATH." >&2 + exit 1 +fi + +if [[ ! -e "$target_path" ]]; then + echo "Error: path does not exist: $target_path" >&2 + exit 1 +fi + +swift-format format "$target_path" --recursive --parallel -i diff --git a/skills/swift-test/SKILL.md b/skills/swift-test/SKILL.md new file mode 100644 index 0000000..dde3eb7 --- /dev/null +++ b/skills/swift-test/SKILL.md @@ -0,0 +1,31 @@ +--- +name: swift-test +description: Run Swift package tests with `swift test --parallel`. Use when asked to execute project tests, verify implementation changes, or run the Swift test suite before commit. +--- + +# Swift Test + +## Overview + +Run the repository test suite with the project's parallel test command. +Use the bundled script for consistent test execution and optional extra flags. + +## Run Tests + +1. Run the helper script from the target repository root: + `./skills/swift-test/scripts/run-swift-tests.sh` +2. Pass optional extra `swift test` flags when needed: + `./skills/swift-test/scripts/run-swift-tests.sh --filter narutoTests` + +## Script Behavior + +- Execute: + `swift test --parallel "$@"` +- Always include `--parallel`. +- Forward any additional CLI flags to `swift test`. +- Exit with a clear error if `swift` is unavailable. + +## Validate + +Run: +`mise x python@3.12 -- python /Users/pvzig/.codex/skills/.system/skill-creator/scripts/quick_validate.py skills/swift-test` diff --git a/skills/swift-test/agents/openai.yaml b/skills/swift-test/agents/openai.yaml new file mode 100644 index 0000000..49a1cbf --- /dev/null +++ b/skills/swift-test/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Swift Test" + short_description: "Run swift test with parallel execution" + default_prompt: "Run Swift package tests in parallel" diff --git a/skills/swift-test/scripts/run-swift-tests.sh b/skills/swift-test/scripts/run-swift-tests.sh new file mode 100755 index 0000000..6e47880 --- /dev/null +++ b/skills/swift-test/scripts/run-swift-tests.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v swift >/dev/null 2>&1; then + echo "Error: swift is not available on PATH." >&2 + exit 1 +fi + +swift test --parallel "$@"