diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..1f7a37c --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,40 @@ +{ + "name": "Swift", + "image": "brightdigit/publish-xml:6.4", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": "false", + "username": "vscode", + "upgradePackages": "false" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "os-provided", + "ppa": "false" + } + }, + "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", + "runArgs": [ + "--cap-add=SYS_PTRACE", + "--security-opt", + "seccomp=unconfined" + ], + // Configure tool-specific properties. + "customizations": { + // Configure properties specific to VS Code. + "vscode": { + // Set *default* container specific settings.json values on container create. + "settings": { + "lldb.library": "/usr/lib/liblldb.so" + }, + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "sswg.swift-lang" + ] + } + }, + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Set `remoteUser` to `root` to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "root" +} \ No newline at end of file diff --git a/.github/actions/setup-tools/action.yml b/.github/actions/setup-tools/action.yml new file mode 100644 index 0000000..6e9cd53 --- /dev/null +++ b/.github/actions/setup-tools/action.yml @@ -0,0 +1,26 @@ +name: Setup mise tools +description: >- + Restore (or build and save) the mise tool cache and put the binaries on PATH. + +runs: + using: composite + steps: + - name: Cache mise tools + id: mise-cache + uses: actions/cache@v4 + with: + path: ~/.local/share/mise/installs + key: mise-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.mise.toml') }} + restore-keys: | + mise-v2-${{ runner.os }}-${{ runner.arch }}- + - name: Install mise tools (cache miss) + if: steps.mise-cache.outputs.cache-hit != 'true' + uses: jdx/mise-action@v4 + with: + cache: false + - name: Configure PATH for cached mise tools + if: steps.mise-cache.outputs.cache-hit == 'true' + uses: jdx/mise-action@v4 + with: + install: false + cache: false diff --git a/.github/workflows/Plot.yml b/.github/workflows/Plot.yml new file mode 100644 index 0000000..3950d53 --- /dev/null +++ b/.github/workflows/Plot.yml @@ -0,0 +1,265 @@ +name: Plot + +# Standalone CI for this BrightDigit Swift package on Swift 6.4 (the BrightDigit +# CI template). These five workflows are kept byte-identical except the name above; +# the only per-package difference is externalized to the ENABLE_WASM repo variable. +# Linux + wasm run in the nightly-6.4 container; macOS + the Apple-platform suite +# run on the GitHub-hosted xcode-27 runner with /Applications/Xcode_27.0.app (Xcode 27 / +# Swift 6.4); Windows on a swift.org nightly snapshot; Android via a swift.org SDK +# bundle. The wasm step is gated on ENABLE_WASM — set it to 'false' where a +# dependency can't build for wasm (e.g. Yams). +on: + push: + branches: [main] + tags: ['v*.*.*'] + paths-ignore: + - '**.md' + - 'LICENSE' + pull_request: + paths-ignore: + - '**.md' + - 'LICENSE' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + build-ubuntu: + name: Build on Ubuntu + needs: configure + runs-on: ubuntu-latest + container: swiftlang/swift:nightly-6.4.x-noble + if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + # Ubuntu variants from a matrix (configure → ubuntu-type): standard ('') always; + # wasm + wasm-embedded when ENABLE_WASM != 'false'. Both wasm variants share the + # same nightly-6.4.x SDK bundle. OpenAPI packages guard URLSession behind + # #if !os(WASI) so the wasm/embedded legs build. + strategy: + fail-fast: false + matrix: + type: ${{ fromJSON(needs.configure.outputs.ubuntu-type) }} + steps: + - uses: actions/checkout@v6 + - uses: brightdigit/swift-build@v1 + id: build + with: + type: ${{ matrix.type }} + # wasm / wasm-embedded are build-only: WasmKit doesn't mount the Foundation + # resource bundles the tests load, and embedded hits OpenAPIRuntime keypath + # limits at runtime. The standard ('') leg runs the full suite + coverage. + build-only: ${{ matrix.type != '' }} + wasm-sdk-url: https://download.swift.org/swift-6.4.x-branch/wasm-sdk/swift-6.4.x-DEVELOPMENT-SNAPSHOT-2026-06-15-a/swift-6.4.x-DEVELOPMENT-SNAPSHOT-2026-06-15-a_wasm.artifactbundle.tar.gz + wasm-sdk-checksum: c014a0162de6eeeef4f07904ce096ccee5405c1478a12c99a3a9750bd42d7702 + wasm-swift-flags: >- + -Xcc -D_WASI_EMULATED_SIGNAL -Xcc -D_WASI_EMULATED_MMAN + -Xlinker -lwasi-emulated-signal -Xlinker -lwasi-emulated-mman + -Xlinker -lwasi-emulated-getpid + -Xlinker --initial-memory=536870912 -Xlinker --max-memory=536870912 + - name: Install curl (required by Codecov uploader) + if: steps.build.outputs.contains-code-coverage == 'true' + run: | + if ! command -v curl &> /dev/null; then + apt-get update -q + apt-get install -y --no-install-recommends curl ca-certificates + fi + # brightdigit fork of swift-coverage-action: upstream @v5 can't pair the + # profdata with the test binary under Swift 6.4's swiftbuild layout + # (.so + -test-runner, no .xctest) — issue #92. Pinned by SHA. + - uses: brightdigit/swift-coverage-action@2f8538f723b99ab2406ac3a0e5b3355a9de4cf6c + if: steps.build.outputs.contains-code-coverage == 'true' + id: coverage-files + with: + search-paths: .build + fail-on-empty-output: true + - name: Upload coverage to Codecov + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v7 + with: + fail_ci_if_error: false + flags: noble,swift-6.4 + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }} + + configure: + name: Configure Matrix + runs-on: ubuntu-latest + outputs: + full-matrix: ${{ steps.check.outputs.full }} + run-windows: ${{ steps.check.outputs.windows }} + ubuntu-type: ${{ steps.check.outputs.ubuntu-type }} + steps: + - uses: actions/checkout@v6 + - id: check + name: Determine matrix scope + run: | + # Three tiers: small set (build-ubuntu[+wasm]/build-macos/lint, always) < + # full-matrix (macOS-platforms/android) < +Windows. full-matrix runs on + # main / semver / dispatch / PRs into main or semver. Windows is the most + # expensive leg, so run-windows is the same MINUS PRs into semver branches. + FULL=false; WIN=false + REF="${{ github.ref }}"; EVENT="${{ github.event_name }}"; BASE_REF="${{ github.base_ref }}" + if [[ "$REF" == "refs/heads/main" ]]; then FULL=true; WIN=true + elif [[ "$REF" =~ ^refs/heads/v?[0-9]+\.[0-9]+\.[0-9]+ ]]; then FULL=true; WIN=true + elif [[ "$EVENT" == "workflow_dispatch" ]]; then FULL=true; WIN=true + elif [[ "$EVENT" == "pull_request" ]]; then + if [[ "$BASE_REF" == "main" ]]; then FULL=true; WIN=true + elif [[ "$BASE_REF" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+ ]]; then FULL=true + fi + fi + echo "full=$FULL" >> "$GITHUB_OUTPUT" + echo "windows=$WIN" >> "$GITHUB_OUTPUT" + # Ubuntu build variants: standard ('') always; wasm + wasm-embedded + # unless the package disables wasm via ENABLE_WASM (e.g. Yams). + if [[ "${{ vars.ENABLE_WASM }}" != "false" ]]; then + echo 'ubuntu-type=["","wasm","wasm-embedded"]' >> "$GITHUB_OUTPUT" + else + echo 'ubuntu-type=[""]' >> "$GITHUB_OUTPUT" + fi + + # macOS on Swift 6.4 via the GitHub-hosted xcode-27 runner. + build-macos: + name: Build on macOS + runs-on: xcode-27 + if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + steps: + - uses: actions/checkout@v6 + - uses: brightdigit/swift-build@v1 + id: build + with: + xcode: "/Applications/Xcode_27.0.app" + - name: Process coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: sersoft-gmbh/swift-coverage-action@v5 + with: + search-paths: .build + - name: Upload coverage to Codecov + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v7 + with: + fail_ci_if_error: false + flags: spm,macos + token: ${{ secrets.CODECOV_TOKEN }} + + # Windows nightly 6.4 via a swift.org development snapshot (compnerd/gha-setup-swift + # under swift-build), on hosted runners. Own tier (run-windows): the most + # expensive leg, so it runs on main / semver / dispatch / PRs into main, but NOT + # PRs into semver branches. Bump the snapshot id periodically (swift.org GCs them). + build-windows: + name: Build on Windows + needs: configure + if: ${{ needs.configure.outputs.run-windows == 'true' }} + runs-on: ${{ matrix.runs-on }} + strategy: + fail-fast: false + matrix: + runs-on: [windows-2022, windows-2025] + swift: + - version: swift-6.4.x-branch + build: 6.4.x-DEVELOPMENT-SNAPSHOT-2026-06-01-a + steps: + - uses: actions/checkout@v6 + - uses: brightdigit/swift-build@v1 + id: build + with: + windows-swift-version: ${{ matrix.swift.version }} + windows-swift-build: ${{ matrix.swift.build }} + + # Full Apple-platform suite (iOS/watchOS/tvOS) on Swift 6.4, on the GitHub-hosted + # xcode-27 runner with downloadable 27.0 simulator runtimes. Blocking leg; simulator runs on the nightly + # toolchain are the most fragile leg. Bump deviceName/osVersion as Xcode-beta moves. + # The watchOS leg is gated on the ENABLE_WATCHOS repo variable (set 'false' to skip it + # where the watchOS-27 SDK rejects deps inferring 8.0 — SwiftPM #10188; see + # brightdigit.com#119). iOS/tvOS always run. + build-macos-platforms: + name: Build on macOS (Platforms) + needs: configure + if: ${{ needs.configure.outputs.full-matrix == 'true' }} + runs-on: xcode-27 + strategy: + fail-fast: false + matrix: + include: + - { type: ios, deviceName: "iPhone 17 Pro", osVersion: "27.0" } + - { type: watchos, deviceName: "Apple Watch Ultra 3 (49mm)", osVersion: "27.0" } + - { type: tvos, deviceName: "Apple TV 4K (3rd generation)", osVersion: "27.0" } + steps: + - uses: actions/checkout@v6 + - name: Build and Test + id: build + # watchOS disabled where ENABLE_WATCHOS=false (SwiftPM #10188, watchOS-27 + # deployment-target clamp); re-enable per brightdigit.com#119. + if: ${{ !(matrix.type == 'watchos' && vars.ENABLE_WATCHOS == 'false') }} + uses: brightdigit/swift-build@v1 + with: + type: ${{ matrix.type }} + xcode: "/Applications/Xcode_27.0.app" + deviceName: ${{ matrix.deviceName }} + osVersion: ${{ matrix.osVersion }} + download-platform: true + - name: Process coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: sersoft-gmbh/swift-coverage-action@v5 + with: + search-paths: .build + - name: Upload coverage to Codecov + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v7 + with: + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} + flags: ${{ format('{0}{1}', matrix.type, matrix.osVersion) }} + + # Android on nightly 6.4 via a swift.org swift-6.4.x-branch snapshot SDK bundle + # (custom-sdk-url path through swift-build → skiptools/swift-android-action). + # Blocking leg; build-only (android-run-tests: false). + # Bump the snapshot id periodically. + build-android: + name: Build on Android + needs: configure + if: ${{ needs.configure.outputs.full-matrix == 'true' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + android-api-level: [34] + steps: + - uses: actions/checkout@v6 + - name: Free disk space + uses: jlumbroso/free-disk-space@v1.3.1 + with: + tool-cache: false + android: false + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: true + - uses: brightdigit/swift-build@v1 + with: + type: android + android-swift-version: 6.4.x-DEVELOPMENT-SNAPSHOT-2026-06-15-a + android-sdk-url: https://download.swift.org/swift-6.4.x-branch/android-sdk/swift-6.4.x-DEVELOPMENT-SNAPSHOT-2026-06-15-a/swift-6.4.x-DEVELOPMENT-SNAPSHOT-2026-06-15-a_android.artifactbundle.tar.gz + android-sdk-id: swift-6.4.x-DEVELOPMENT-SNAPSHOT-2026-06-15-a_android + android-api-level: ${{ matrix.android-api-level }} + android-run-tests: false + + lint: + name: Linting + if: ${{ !cancelled() && !failure() && !contains(github.event.head_commit.message, 'ci skip') }} + needs: [build-ubuntu, build-macos, build-windows, build-macos-platforms] + runs-on: ubuntu-latest + container: swiftlang/swift:nightly-6.4.x-noble + steps: + - name: Install curl (required by setup-tools) + run: | + apt-get update -q + apt-get install -y --no-install-recommends curl ca-certificates + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-tools + - name: Lint + run: ./Scripts/lint.sh + env: + LINT_MODE: STRICT diff --git a/.github/workflows/check-unsafe-flags.yml b/.github/workflows/check-unsafe-flags.yml new file mode 100644 index 0000000..3af5e56 --- /dev/null +++ b/.github/workflows/check-unsafe-flags.yml @@ -0,0 +1,39 @@ +name: Check for unsafeFlags + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + dump-package-check: + name: Dump Swift package (authoritative) and scan JSON + runs-on: ubuntu-latest + container: + image: swiftlang/swift:nightly-6.4.x-noble + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install jq + run: | + apt-get update && apt-get install -y jq + + - name: Dump package JSON and check for unsafeFlags + shell: bash + run: | + set -euo pipefail + # Compute unsafeFlags array directly from the dump (don't store the full dump variable) + unsafe_flags=$(swift package dump-package | jq -c '[.. | objects | .unsafeFlags? // empty]') + # Check array length to decide failure + if [ "$(echo "$unsafe_flags" | jq 'length')" -gt 0 ]; then + echo "ERROR: unsafeFlags found in resolved package JSON:" + echo "$unsafe_flags" | jq '.' || true + echo "--- resolved package dump (first 200 lines) ---" + # Print a sample of the authoritative dump (re-run dump-package for the sample) + swift package dump-package | sed -n '1,200p' || true + exit 1 + else + echo "No unsafeFlags in resolved package JSON." + fi diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 0000000..b5e8cfd --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,44 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' + plugins: 'code-review@claude-code-plugins' + prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 0000000..6b15fac --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,50 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr *)' + diff --git a/.github/workflows/cleanup-caches.yml b/.github/workflows/cleanup-caches.yml new file mode 100644 index 0000000..f0124e2 --- /dev/null +++ b/.github/workflows/cleanup-caches.yml @@ -0,0 +1,29 @@ +name: Cleanup Branch Caches +on: + delete: + +jobs: + cleanup: + runs-on: ubuntu-latest + permissions: + actions: write + steps: + - name: Cleanup caches for deleted branch + uses: actions/github-script@v9 + with: + script: | + const ref = `refs/heads/${context.payload.ref}`; + const caches = await github.rest.actions.getActionsCacheList({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: ref, + }); + for (const cache of caches.data.actions_caches) { + console.log(`Deleting cache: ${cache.key}`); + await github.rest.actions.deleteActionsCacheById({ + owner: context.repo.owner, + repo: context.repo.repo, + cache_id: cache.id, + }); + } + console.log(`Deleted ${caches.data.actions_caches.length} cache(s) for ${ref}`); diff --git a/.github/workflows/swift-source-compat.yml b/.github/workflows/swift-source-compat.yml new file mode 100644 index 0000000..d4d03bb --- /dev/null +++ b/.github/workflows/swift-source-compat.yml @@ -0,0 +1,28 @@ +name: Swift Source Compatibility + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + swift-source-compat-suite: + name: Test Swift ${{ matrix.container }} For Source Compatibility Suite + runs-on: ubuntu-latest + if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + + strategy: + fail-fast: false + matrix: + container: + - swiftlang/swift:nightly-6.4.x-noble + + container: ${{ matrix.container }} + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Test Swift 6.x For Source Compatibility + run: swift build --disable-sandbox --verbose --configuration release diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..87bc056 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,16 @@ +[settings] +# Enable experimental features (required for the spm backend) +experimental = true + +[tools] +# Swift development tools (managed via mise) +# 604.x aligns swift-format (and its swift-syntax) with the Swift 6.4 toolchain. +# Only one 604 tag exists so far; bump when a newer 604 prerelease/release lands. +"spm:swiftlang/swift-format" = "604.0.0-prerelease-2025-12-17" +"aqua:realm/SwiftLint" = "0.63.2" +"spm:peripheryapp/periphery" = "3.7.4" + +[tasks] +swift-format = "swift-format" +swiftlint = "swiftlint" +periphery = "periphery" diff --git a/.periphery.yml b/.periphery.yml new file mode 100644 index 0000000..b0e0637 --- /dev/null +++ b/.periphery.yml @@ -0,0 +1,3 @@ +retain_public: true +targets: +- Plot diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..a69a184 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,6 @@ +version: 1 +builder: + configs: + - documentation_targets: [Plot] + swift_version: 5.9 + custom_documentation_parameters: [--include-extended-types] diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..257f555 --- /dev/null +++ b/.swift-format @@ -0,0 +1,70 @@ +{ + "fileScopedDeclarationPrivacy" : { + "accessLevel" : "fileprivate" + }, + "indentation" : { + "spaces" : 2 + }, + "indentConditionalCompilationBlocks" : true, + "indentSwitchCaseLabels" : false, + "lineBreakAroundMultilineExpressionChainComponents" : false, + "lineBreakBeforeControlFlowKeywords" : false, + "lineBreakBeforeEachArgument" : false, + "lineBreakBeforeEachGenericRequirement" : false, + "lineLength" : 100, + "maximumBlankLines" : 1, + "multiElementCollectionTrailingCommas" : true, + "noAssignmentInExpressions" : { + "allowedFunctions" : [ + "XCTAssertNoThrow" + ] + }, + "prioritizeKeepingFunctionOutputTogether" : false, + "respectsExistingLineBreaks" : true, + "rules" : { + "AllPublicDeclarationsHaveDocumentation" : true, + "AlwaysUseLiteralForEmptyCollectionInit" : false, + "AlwaysUseLowerCamelCase" : true, + "AmbiguousTrailingClosureOverload" : true, + "BeginDocumentationCommentWithOneLineSummary" : false, + "DoNotUseSemicolons" : true, + "DontRepeatTypeInStaticProperties" : true, + "FileScopedDeclarationPrivacy" : false, + "FullyIndirectEnum" : true, + "GroupNumericLiterals" : true, + "IdentifiersMustBeASCII" : true, + "NeverForceUnwrap" : true, + "NeverUseForceTry" : true, + "NeverUseImplicitlyUnwrappedOptionals" : true, + "NoAccessLevelOnExtensionDeclaration" : true, + "NoAssignmentInExpressions" : true, + "NoBlockComments" : true, + "NoCasesWithOnlyFallthrough" : true, + "NoEmptyTrailingClosureParentheses" : true, + "NoLabelsInCasePatterns" : true, + "NoLeadingUnderscores" : true, + "NoParensAroundConditions" : true, + "NoPlaygroundLiterals" : true, + "NoVoidReturnOnFunctionSignature" : true, + "OmitExplicitReturns" : false, + "OneCasePerLine" : true, + "OneVariableDeclarationPerLine" : true, + "OnlyOneTrailingClosureArgument" : true, + "OrderedImports" : true, + "ReplaceForEachWithForLoop" : true, + "ReturnVoidInsteadOfEmptyTuple" : true, + "TypeNamesShouldBeCapitalized" : true, + "UseEarlyExits" : false, + "UseExplicitNilCheckInConditions" : true, + "UseLetInEveryBoundCaseVariable" : true, + "UseShorthandTypeNames" : true, + "UseSingleLinePropertyGetter" : true, + "UseSynthesizedInitializer" : true, + "UseTripleSlashForDocumentationComments" : true, + "UseWhereClausesInForLoops" : true, + "ValidateDocumentationComments" : true + }, + "spacesAroundRangeFormationOperators" : false, + "tabWidth" : 2, + "version" : 1 +} diff --git a/.swift-version b/.swift-version new file mode 100644 index 0000000..944c7e2 --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +6.4.x-snapshot diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..4ccdb85 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,149 @@ +opt_in_rules: + - array_init + - closure_body_length + - closure_end_indentation + - closure_spacing + - collection_alignment + - conditional_returns_on_newline + - contains_over_filter_count + - contains_over_filter_is_empty + - contains_over_first_not_nil + - contains_over_range_nil_comparison + - convenience_type + - discouraged_object_literal + - empty_collection_literal + - empty_count + - empty_string + - empty_xctest_method + - enum_case_associated_values_count + - expiring_todo + - explicit_acl + - explicit_init + - explicit_top_level_acl + # - fallthrough + - fatal_error_message + - file_name + - file_name_no_space + - file_types_order + - first_where + - flatmap_over_map_reduce + - force_unwrapping +# - function_default_parameter_at_end + - ibinspectable_in_extension + - identical_operands + - implicit_return + - implicitly_unwrapped_optional + - indentation_width + - joined_default_parameter + - last_where + - legacy_multiple + - legacy_random + - literal_expression_end_indentation + - lower_acl_than_parent + - missing_docs + - modifier_order + - multiline_arguments + - multiline_arguments_brackets + - multiline_function_chains + - multiline_literal_brackets + - multiline_parameters + - nimble_operator + - nslocalizedstring_key + - nslocalizedstring_require_bundle + - number_separator + - object_literal + - one_declaration_per_file + - operator_usage_whitespace + - optional_enum_case_matching + - overridden_super_call + - override_in_extension + - pattern_matching_keywords + - prefer_self_type_over_type_of_self + - prefer_zero_over_explicit_init + - private_action + - private_outlet + - prohibited_interface_builder + - prohibited_super_call + - quick_discouraged_call + - quick_discouraged_focused_test + - quick_discouraged_pending_test + - reduce_into + - redundant_nil_coalescing + - redundant_type_annotation + - required_enum_case + - single_test_class + - sorted_first_last + - sorted_imports + - static_operator + - strong_iboutlet + - toggle_bool +# - trailing_closure + - type_contents_order + - unavailable_function + - unneeded_parentheses_in_closure_argument + - unowned_variable_capture + - untyped_error_in_catch + - vertical_parameter_alignment_on_call + - vertical_whitespace_closing_braces + - vertical_whitespace_opening_braces + - xct_specific_matcher + - yoda_condition +analyzer_rules: + - unused_import + - unused_declaration +cyclomatic_complexity: + - 6 + - 12 +file_length: + warning: 225 + error: 300 +function_body_length: + - 50 + - 76 +function_parameter_count: 8 +line_length: + - 108 + - 200 +closure_body_length: + - 50 + - 60 +type_name: + min_length: 3 + max_length: + warning: 50 + error: 60 +identifier_name: + excluded: + - id + - no +excluded: + - DerivedData + - .build + - .swiftpm + - "*/.swiftpm" + - "*/.build" + - Mint + - Examples + - Packages + - Package.swift +indentation_width: + indentation_width: 2 +file_name: + severity: error +fatal_error_message: + severity: error +disabled_rules: + - nesting + - implicit_getter + - switch_case_alignment + - closure_parameter_position + - trailing_comma + - opening_brace + - optional_data_string_conversion + - pattern_matching_keywords +custom_rules: + no_unchecked_sendable: + name: "No Unchecked Sendable" + regex: '@unchecked\s+Sendable' + message: "Use proper Sendable conformance instead of @unchecked Sendable to maintain strict concurrency safety" + severity: error diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1c91f5b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,12 @@ +# CLAUDE.md — Plot + +Vendored package in the BrightDigit Publish stack (`Packages/Publish/Plot`), +maintained in-tree on plain Swift 6.4 (`// swift-tools-version:6.4`), macOS 15+. + +- Strict concurrency is mandatory. Resolve every diagnostic properly + (Sendable / isolation / `Synchronization.Mutex`); never use + `@unchecked Sendable` — a SwiftLint `no_unchecked_sendable` rule enforces this. +- Lint: `LINT_MODE=STRICT ./Scripts/lint.sh` — full BrightDigit house style + (swift-format + SwiftLint + build) gates; periphery is local-only (skipped + when `$CI` is set). +- Tests use XCTest (kept as-is). diff --git a/Package.swift b/Package.swift index ea8d804..e6992c9 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.4 +// swift-tools-version:6.4 /** * Plot diff --git a/Scripts/header.sh b/Scripts/header.sh new file mode 100755 index 0000000..c571c18 --- /dev/null +++ b/Scripts/header.sh @@ -0,0 +1,104 @@ +#!/bin/bash + +# Function to print usage +usage() { + echo "Usage: $0 -d directory -c creator -o company -p package [-y year]" + echo " -d directory Directory to read from (including subdirectories)" + echo " -c creator Name of the creator" + echo " -o company Name of the company with the copyright" + echo " -p package Package or library name" + echo " -y year Copyright year (optional, defaults to current year)" + exit 1 +} + +# Get the current year if not provided +current_year=$(date +"%Y") + +# Default values +year="$current_year" + +# Parse arguments +while getopts ":d:c:o:p:y:" opt; do + case $opt in + d) directory="$OPTARG" ;; + c) creator="$OPTARG" ;; + o) company="$OPTARG" ;; + p) package="$OPTARG" ;; + y) year="$OPTARG" ;; + *) usage ;; + esac +done + +# Check for mandatory arguments +if [ -z "$directory" ] || [ -z "$creator" ] || [ -z "$company" ] || [ -z "$package" ]; then + usage +fi + +# Define the header template +header_template="// +// %s +// %s +// +// Created by %s. +// Copyright © %s %s. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the \"Software\"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +//" + +# Loop through each Swift file in the specified directory and subdirectories +find "$directory" -type f -name "*.swift" | while read -r file; do + # Skip files in the Generated directory + if [[ "$file" == *"/Generated/"* ]]; then + echo "Skipping $file (generated file)" + continue + fi + + # Check if the first line is the swift-format-ignore indicator + first_line=$(head -n 1 "$file") + if [[ "$first_line" == "// swift-format-ignore-file" ]]; then + echo "Skipping $file due to swift-format-ignore directive." + continue + fi + + # Create the header with the current filename + filename=$(basename "$file") + header=$(printf "$header_template" "$filename" "$package" "$creator" "$year" "$company") + + # Remove all consecutive lines at the beginning which start with "// ", contain only whitespace, or only "//" + awk ' + BEGIN { skip = 1 } + { + if (skip && ($0 ~ /^\/\/ / || $0 ~ /^\/\/$/ || $0 ~ /^$/)) { + next + } + skip = 0 + print + }' "$file" > temp_file + + # Add the header to the cleaned file + (echo "$header"; echo; cat temp_file) > "$file" + + # Remove the temporary file + rm temp_file +done + +echo "Headers added or files skipped appropriately across all Swift files in the directory and subdirectories." diff --git a/Scripts/lint.sh b/Scripts/lint.sh new file mode 100755 index 0000000..3d72311 --- /dev/null +++ b/Scripts/lint.sh @@ -0,0 +1,101 @@ +#!/bin/bash + +# Remove set -e to allow script to continue running +# set -e # Exit on any error + +ERRORS=0 + +run_command() { + "$@" || ERRORS=$((ERRORS + 1)) +} + +if [ "$LINT_MODE" = "INSTALL" ]; then + exit +fi + +echo "LintMode: $LINT_MODE" + +# More portable way to get script directory +if [ -z "$SRCROOT" ]; then + SCRIPT_DIR=$(dirname "$(readlink -f "$0")") + PACKAGE_DIR="${SCRIPT_DIR}/.." +else + PACKAGE_DIR="${SRCROOT}" +fi + +# Detect if mise is available +# Check common installation paths for mise +MISE_PATHS=( + "/opt/homebrew/bin/mise" + "/usr/local/bin/mise" + "$HOME/.local/bin/mise" +) + +MISE_BIN="" +for mise_path in "${MISE_PATHS[@]}"; do + if [ -x "$mise_path" ]; then + MISE_BIN="$mise_path" + break + fi +done + +# Fallback to PATH lookup +if [ -z "$MISE_BIN" ] && command -v mise &> /dev/null; then + MISE_BIN="mise" +fi + +if [ -n "$MISE_BIN" ]; then + TOOL_CMD="$MISE_BIN exec --" +else + echo "Error: mise is not installed" + echo "Install mise: https://mise.jdx.dev/getting-started.html" + echo "Checked paths: ${MISE_PATHS[*]}" + exit 1 +fi + +if [ "$LINT_MODE" = "NONE" ]; then + exit +elif [ "$LINT_MODE" = "STRICT" ]; then + SWIFTFORMAT_LINT_OPTIONS="--strict" + SWIFTLINT_OPTIONS="--strict" +else + SWIFTFORMAT_LINT_OPTIONS="" + SWIFTLINT_OPTIONS="" +fi + +pushd $PACKAGE_DIR + +# Bootstrap tools (mise will install based on .mise.toml) +run_command "$MISE_BIN" install + +if [ -z "$CI" ]; then + run_command $TOOL_CMD swift-format format --configuration .swift-format --recursive --parallel --in-place Sources Tests + run_command $TOOL_CMD swiftlint --fix +fi + +if [ -z "$FORMAT_ONLY" ]; then + run_command $TOOL_CMD swift-format lint --configuration .swift-format --recursive --parallel $SWIFTFORMAT_LINT_OPTIONS Sources Tests + run_command $TOOL_CMD swiftlint lint $SWIFTLINT_OPTIONS + # Check for compilation errors + run_command swift build --build-tests +fi + +# header.sh rewrites file headers in place, so it only runs locally — never in CI. +if [ -z "$CI" ]; then + $PACKAGE_DIR/Scripts/header.sh -d $PACKAGE_DIR/Sources -c "Leo Dion" -o "BrightDigit" -p "Plot" +fi + +if [ -z "$CI" ]; then + run_command $TOOL_CMD periphery scan $PERIPHERY_OPTIONS --disable-update-check +fi + +popd + +# Exit with error code if any errors occurred +if [ $ERRORS -gt 0 ]; then + echo "Linting completed with $ERRORS error(s)" + exit 1 +else + echo "Linting completed successfully" + exit 0 +fi diff --git a/Sources/Plot/API/Attribute+HTML.swift b/Sources/Plot/API/Attribute+HTML.swift new file mode 100644 index 0000000..6a09ba5 --- /dev/null +++ b/Sources/Plot/API/Attribute+HTML.swift @@ -0,0 +1,173 @@ +/** +* Plot +* Copyright (c) John Sundell 2019 +* MIT license, see LICENSE file for details +*/ + +extension Attribute where Context: HTMLContext { + /// Assign an ID to the current element. + /// - parameter id: The ID to assign. + /// - Returns: The created attribute. + public static func id(_ id: String) -> Attribute { + Attribute(name: "id", value: id) + } + + /// Assign a class name to the current element. May also be a list of + /// space-separated class names. + /// - parameter className: The class or list of classes to assign. + /// - Returns: The created attribute. + public static func `class`(_ className: String) -> Attribute { + Attribute(name: "class", value: className) + } + + /// Add a `data-` attribute to the current element. + /// - Parameters: + /// - name: The name of the attribute to add. The name will + /// be prefixed with `data-`. + /// - value: The attribute's string value. + /// - Returns: The created attribute. + public static func data(named name: String, value: String) -> Attribute { + Attribute(name: "data-\(name)", value: value) + } + + /// Assign whether operating system level spell checking should be enabled. + /// - parameter isEnabled: Whether spell checking should be enabled. + /// - Returns: The created attribute. + public static func spellcheck(_ isEnabled: Bool) -> Attribute { + Attribute(name: "spellcheck", value: String(isEnabled)) + } + + /// Specify a title for the element. + /// - parameter title: The title to assign to the element. + /// - Returns: The created attribute. + public static func title(_ title: String) -> Attribute { + Attribute(name: "title", value: title) + } + + /// Specify a directionality for the element. + /// - parameter directionality: The directionality to assign to the element. + /// - Returns: The created attribute. + public static func dir(_ directionality: Directionality) -> Attribute { + Attribute(name: "dir", value: directionality.rawValue) + } +} + +extension Node where Context: HTMLContext { + /// Assign an ID to the current element. + /// - parameter id: The ID to assign. + /// - Returns: The created node. + public static func id(_ id: String) -> Node { + .attribute(named: "id", value: id) + } + + /// Assign a class name to the current element. May also be a list of + /// space-separated class names. + /// - parameter className: The class or list of classes to assign. + /// - Returns: The created node. + public static func `class`(_ className: String) -> Node { + .attribute(named: "class", value: className) + } + + /// Add a `data-` attribute to the current element. + /// - Parameters: + /// - name: The name of the attribute to add. The name will + /// be prefixed with `data-`. + /// - value: The attribute's string value. + /// - Returns: The created node. + public static func data(named name: String, value: String) -> Node { + .attribute(named: "data-\(name)", value: value) + } + + /// Assign whether operating system level spell checking should be enabled. + /// - parameter isEnabled: Whether spell checking should be enabled. + /// - Returns: The created node. + public static func spellcheck(_ isEnabled: Bool) -> Node { + .attribute(named: "spellcheck", value: String(isEnabled)) + } + + /// Specify a title for the element. + /// - parameter title: The title to assign to the element. + /// - Returns: The created node. + public static func title(_ title: String) -> Node { + .attribute(named: "title", value: title) + } + + /// Assign whether the element should be hidden. + /// - parameter isHidden: Whether the element should be hidden or not. + /// - Returns: The created node. + public static func hidden(_ isHidden: Bool) -> Node { + isHidden ? .attribute(named: "hidden") : .empty + } + + /// Specify a directionality for the element. + /// - parameter directionality: The directionality to assign to the element. + /// - Returns: The created node. + public static func dir(_ directionality: Directionality) -> Node { + .attribute(named: "dir", value: directionality.rawValue) + } +} + +extension Attribute where Context: HTMLNamableContext { + /// Assign a name to the element. + /// - parameter name: The name to assign. + /// - Returns: The created attribute. + public static func name(_ name: String) -> Attribute { + Attribute(name: "name", value: name) + } +} + +extension Node where Context: HTMLNamableContext { + /// Assign a name to the element. + /// - parameter name: The name to assign. + /// - Returns: The created node. + public static func name(_ name: String) -> Node { + .attribute(named: "name", value: name) + } +} + +extension Attribute where Context == HTML.MetaContext { + /// Assign a property to the element. + /// - parameter property: The property to assign. + /// - Returns: The created attribute. + public static func property(_ property: String) -> Attribute { + Attribute(name: "property", value: property) + } +} + +extension Attribute where Context: HTMLTypeContext { + /// Assign a type string to this element. + /// - parameter type: The name of the type to assign. + /// - Returns: The created attribute. + public static func type(_ type: String) -> Attribute { + Attribute(name: "type", value: type) + } +} + +extension Attribute where Context: HTMLValueContext { + /// Assign a string value to the element. + /// - parameter value: The value to assign. + /// - Returns: The created attribute. + public static func value(_ value: String) -> Attribute { + Attribute(name: "value", value: value) + } +} + +extension Node where Context: HTMLValueContext { + /// Assign a string value to the element. + /// - parameter value: The value to assign. + /// - Returns: The created node. + public static func value(_ value: String) -> Node { + .attribute(named: "value", value: value) + } +} + +// MARK: - Document + +extension Node where Context == HTML.DocumentContext { + /// Specify the language of the HTML document's content. + /// - parameter language: The language to specify. + /// - Returns: The created node. + public static func lang(_ language: Language) -> Node { + .attribute(named: "lang", value: language.rawValue) + } +} diff --git a/Sources/Plot/API/Attribute+HTMLForms.swift b/Sources/Plot/API/Attribute+HTMLForms.swift new file mode 100644 index 0000000..cb61a17 --- /dev/null +++ b/Sources/Plot/API/Attribute+HTMLForms.swift @@ -0,0 +1,195 @@ +/** +* Plot +* Copyright (c) John Sundell 2019 +* MIT license, see LICENSE file for details +*/ + +extension Node where Context == HTML.FormContext { + /// Assign a URL that this form should be sent to when submitted. + /// - parameter url: The action URL that the form should be sent to. + /// - Returns: The created node. + public static func action(_ url: URLRepresentable) -> Node { + .attribute(named: "action", value: url.string) + } + + /// Assign a specific content type to the form. + /// - Parameter type: The content type to assign. + /// - Returns: The created node. + public static func enctype(_ type: HTMLFormContentType) -> Node { + .attribute(named: "enctype", value: type.rawValue) + } + + /// Assign a specific HTTP request method that the form should + /// be submitted using. + /// - Parameter method: The HTTP request method to use. + /// - Returns: The created node. + public static func method(_ method: HTMLFormMethod) -> Node { + .attribute(named: "method", value: method.rawValue) + } + + /// Add the `novalidate` attribute to the form, which + /// disables any native browser validation on the form. + /// - parameter isOn: Whether validation should be disabled. + /// - Returns: The created node. + public static func novalidate(_ isOn: Bool = true) -> Node { + isOn ? .attribute(named: "novalidate") : .empty + } +} + +extension Node where Context == HTML.LabelContext { + /// Assign which input control that this label is for. + /// - parameter target: The target input control's name. + /// - Returns: The created node. + public static func `for`(_ target: String) -> Node { + .attribute(named: "for", value: target) + } +} + +extension Attribute where Context == HTML.InputContext { + /// Assign an input type to the element. + /// - parameter type: The input type to assign. + /// - Returns: The created attribute. + public static func type(_ type: HTMLInputType) -> Attribute { + Attribute(name: "type", value: type.rawValue) + } + + /// Assign a placeholder to the input field. + /// - parameter placeholder: The placeholder to assign. + /// - Returns: The created attribute. + public static func placeholder(_ placeholder: String) -> Attribute { + Attribute(name: "placeholder", value: placeholder) + } + + /// Assign whether the element should have autocomplete turned on or off. + /// - parameter isOn: Whether autocomplete should be turned on. + /// - Returns: The created attribute. + public static func autocomplete(_ isOn: Bool) -> Attribute { + Attribute(name: "autocomplete", value: isOn ? "on" : "off") + } + + /// Assign whether the element is required before submitting the form. + /// - parameter isRequired: Whether the element is required. + /// - Returns: The created attribute. + public static func required(_ isRequired: Bool) -> Attribute { + isRequired ? Attribute(name: "required", value: nil, ignoreIfValueIsEmpty: false) : .empty + } + + /// Assign whether the element should be autofocused when the page loads. + /// - parameter isOn: Whether autofocus should be turned on. + /// - Returns: The created attribute. + public static func autofocus(_ isOn: Bool) -> Attribute { + isOn ? Attribute(name: "autofocus", value: nil, ignoreIfValueIsEmpty: false) : .empty + } + + /// Assign whether the element should be read-only. + /// - parameter isReadonly: Whether the input is read-only. + /// - Returns: The created attribute. + public static func readonly(_ isReadonly: Bool) -> Attribute { + isReadonly ? Attribute(name: "readonly", value: nil, ignoreIfValueIsEmpty: false) : .empty + } + + /// Assign whether the element should be disabled. + /// - parameter isDisabled: Whether the input is disabled. + /// - Returns: The created attribute. + public static func disabled(_ isDisabled: Bool) -> Attribute { + isDisabled ? Attribute(name: "disabled", value: nil, ignoreIfValueIsEmpty: false) : .empty + } + + /// Assign whether the element should allow the selection of multiple values. + /// - parameter isEnabled: Whether multiple values are allowed. + /// - Returns: The created attribute. + public static func multiple(_ isEnabled: Bool) -> Attribute { + isEnabled ? Attribute(name: "multiple", value: nil, ignoreIfValueIsEmpty: false) : .empty + } + + /// Assign whether a checkbox or radio input element has an active state. + /// - parameter isChecked: Whether the element has an active state. + /// - Returns: The created attribute. + public static func checked(_ isChecked: Bool) -> Attribute { + isChecked ? Attribute(name: "checked", value: nil, ignoreIfValueIsEmpty: false) : .empty + } +} + +extension Node where Context == HTML.ButtonContext { + /// Assign a button type to the element. + /// - parameter type: The button type to assign. + /// - Returns: The created node. + public static func type(_ type: HTMLButtonType) -> Node { + .attribute(named: "type", value: type.rawValue) + } +} + +extension Node where Context == HTML.TextAreaContext { + /// Specify the number of columns that the text area should contain. + /// - parameter columns: The number of columns to specify. + /// - Returns: The created node. + public static func cols(_ columns: Int) -> Node { + .attribute(named: "cols", value: String(columns)) + } + + /// Specify the number of text rows that should be visible within the text area. + /// - parameter rows: The number of rows to specify. + /// - Returns: The created node. + public static func rows(_ rows: Int) -> Node { + .attribute(named: "rows", value: String(rows)) + } + + /// Assign a placeholder to the text area. + /// - parameter placeholder: The placeholder to assign. + /// - Returns: The created node. + public static func placeholder(_ placeholder: String) -> Node { + .attribute(named: "placeholder", value: placeholder) + } + + /// Assign whether the element is required before submitting the form. + /// - parameter isRequired: Whether the element is required. + /// - Returns: The created node. + public static func required(_ isRequired: Bool) -> Node { + isRequired ? .attribute(named: "required") : .empty + } + + /// Assign whether the element should be autofocused when the page loads. + /// - parameter isOn: Whether autofocus should be turned on. + /// - Returns: The created node. + public static func autofocus(_ isOn: Bool) -> Node { + isOn ? .attribute(named: "autofocus") : .empty + } + + /// Assign whether the element should be read-only. + /// - parameter isReadonly: Whether the input is read-only. + /// - Returns: The created node. + public static func readonly(_ isReadonly: Bool) -> Node { + isReadonly ? .attribute(named: "readonly") : .empty + } + + /// Assign whether the element should be disabled. + /// - parameter isDisabled: Whether the input is disabled. + /// - Returns: The created node. + public static func disabled(_ isDisabled: Bool) -> Node { + isDisabled ? .attribute(named: "disabled") : .empty + } +} + +extension Attribute where Context == HTML.OptionContext { + /// Specify whether the option should be initially selected. + /// - parameter isSelected: Whether the option should be selected. + /// - Returns: The created attribute. + public static func isSelected(_ isSelected: Bool) -> Attribute { + guard isSelected else { + return .empty + } + + return Attribute( + name: "selected", + value: nil, + ignoreIfValueIsEmpty: false + ) + } + + /// Assign a label to the given option. + /// - parameter label: The user displayed value of the option + /// - Returns: The created attribute. + public static func label(_ label: String) -> Attribute { + Attribute(name: "label", value: label, ignoreIfValueIsEmpty: false) + } +} diff --git a/Sources/Plot/API/Attribute+HTMLLinks.swift b/Sources/Plot/API/Attribute+HTMLLinks.swift new file mode 100644 index 0000000..2a2b995 --- /dev/null +++ b/Sources/Plot/API/Attribute+HTMLLinks.swift @@ -0,0 +1,190 @@ +/** +* Plot +* Copyright (c) John Sundell 2019 +* MIT license, see LICENSE file for details +*/ + +extension Attribute where Context == HTML.LinkContext { + /// Assign a relationship to the link. See `HTMLLinkRelationship` for more info. + /// - parameter relationship: The relationship to assign. + /// - Returns: The created attribute. + public static func rel(_ relationship: HTMLLinkRelationship) -> Attribute { + Attribute(name: "rel", value: relationship.rawValue) + } + + /// Assign an `hreflang` attribute to this element. + /// - parameter language: The language to assign. + /// - Returns: The created attribute. + public static func hreflang(_ language: Language) -> Attribute { + Attribute(name: "hreflang", value: language.rawValue) + } + + /// Assign an icon sizes string to this element. + /// - parameter sizes: The icon sizes string to assign. + /// - Returns: The created attribute. + public static func sizes(_ sizes: String) -> Attribute { + Attribute(name: "sizes", value: sizes) + } + + /// Assign an icon color string to this element. + /// - parameter color: The icon color string to assign. + /// - Returns: The created attribute. + public static func color(_ color: String) -> Attribute { + Attribute(name: "color", value: color) + } + + /// Assign whether the link should have the crossorigin attribute. + /// - parameter isEnabled: Whether crossorigin should be enabled. + /// - Returns: The created attribute. + public static func crossorigin(_ isEnabled: Bool) -> Attribute { + isEnabled ? Attribute(name: "crossorigin", value: nil, ignoreIfValueIsEmpty: false) : .empty + } +} + +extension Attribute where Context: HTMLLinkableContext { + /// Assign a URL to link the element to, using its `href` attribute. + /// - parameter url: The URL to assign. + /// - Returns: The created attribute. + public static func href(_ url: URLRepresentable) -> Attribute { + Attribute(name: "href", value: url.string) + } +} + +extension Node where Context: HTMLLinkableContext { + /// Assign a URL to link the element to, using its `href` attribute. + /// - parameter url: The URL to assign. + /// - Returns: The created node. + public static func href(_ url: URLRepresentable) -> Node { + .attribute(named: "href", value: url.string) + } +} + +extension Node where Context == HTML.AnchorContext { + /// Assign a target to the anchor, specifying how its URL should be opened. + /// - parameter target: The target to assign. See `HTMLAnchorTarget`. + /// - Returns: The created node. + public static func target(_ target: HTMLAnchorTarget) -> Node { + .attribute(named: "target", value: target.rawValue) + } + + /// Assign a relationship to the anchor, using its `rel` attribute. + /// - parameter relationship: The relationship to assign. See + /// `HTMLAnchorRelationship` for more info. + /// - Returns: The created node. + public static func rel(_ relationship: HTMLAnchorRelationship) -> Node { + .attribute(named: "rel", value: relationship.rawValue) + } +} + +// MARK: - DateTime + +extension Node where Context == HTML.TimeContext { + /// Attach a datetime to the time element, to translate the element into + /// a machine readable format for browsers. + /// - parameter datetime: The datetime string to attach. See the datetime + /// reference for valid `datetime` values: + /// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/time#valid_datetime_values + /// - Returns: The created node. + public static func datetime(_ datetime: String) -> Node { + .attribute(named: "datetime", value: datetime) + } +} + +// MARK: - Interactive elements + +extension Node where Context == HTML.DetailsContext { + /// Assign whether the details element is opened/expanded. + /// - parameter isOpen: Whether the element should be displayed as open. + /// - Returns: The created node. + public static func open(_ isOpen: Bool) -> Node { + isOpen ? .attribute(named: "open") : .empty + } +} + +// MARK: - Sources and media + +extension Attribute where Context: HTMLSourceContext { + /// Assign a source to the element, using its `src` attribute. + /// - parameter url: The source URL to assign. + /// - Returns: The created attribute. + public static func src(_ url: URLRepresentable) -> Attribute { + Attribute(name: "src", value: url.string) + } +} + +extension Node where Context: HTMLSourceContext { + /// Assign a source to the element, using its `src` attribute. + /// - parameter url: The source URL to assign. + /// - Returns: The created node. + public static func src(_ url: URLRepresentable) -> Node { + .attribute(named: "src", value: url.string) + } +} + +extension Node where Context: HTMLMediaContext { + /// Assign whether the element's media controls should be enabled. + /// - parameter enableControls: Whether controls should be shown. + /// - Returns: The created node. + public static func controls(_ enableControls: Bool) -> Node { + enableControls ? .attribute(named: "controls") : .empty + } +} + +extension Attribute where Context == HTML.ObjectContext { + /// Assign an external resource to the element, using its `data` attribute. + /// - parameter url: The data URL to assign. + /// - Returns: The created attribute. + public static func data(_ url: URLRepresentable) -> Attribute { + Attribute(name: "data", value: url.string) + } +} + +extension Node where Context == HTML.ObjectContext { + /// Assign an external resource to the element, using its `data` attribute. + /// - parameter url: The data URL to assign. + /// - Returns: The created node. + public static func data(_ url: URLRepresentable) -> Node { + .attribute(named: "data", value: url.string) + } +} + +extension Attribute where Context == HTML.AudioSourceContext { + /// Assign a type to this audio source. See `HTMLAudioFormat` for more info. + /// - parameter format: The audio format to assign. + /// - Returns: The created attribute. + public static func type(_ format: HTMLAudioFormat) -> Attribute { + Attribute(name: "type", value: "audio/" + format.rawValue) + } +} + +extension Attribute where Context == HTML.VideoSourceContext { + /// Assign a type to this video source. See `HTMLVideoFormat` for more info. + /// - parameter format: The video format to assign. + /// - Returns: The created attribute. + public static func type(_ format: HTMLVideoFormat) -> Attribute { + Attribute(name: "type", value: "video/" + format.rawValue) + } +} + +extension Attribute where Context == HTML.PictureSourceContext { + /// Assign a string describing a set of sources, using the `srcset` attribute. + /// - parameter set: The set of sources that this element should point to. + /// - Returns: The created attribute. + public static func srcset(_ set: String) -> Attribute { + Attribute(name: "srcset", value: set) + } + + /// Assign a media query that determines whether this source should be used. + /// - parameter query: The media query that the browser should evaluate. + /// - Returns: The created attribute. + public static func media(_ query: String) -> Attribute { + Attribute(name: "media", value: query) + } + + /// Assign a string describing the MIME type, using the `type` attribute. + /// - parameter type: The type (MIME type) for this element. + /// - Returns: The created attribute. + public static func type(_ type: String) -> Attribute { + Attribute(name: "type", value: type) + } +} diff --git a/Sources/Plot/API/Attribute+HTMLMedia.swift b/Sources/Plot/API/Attribute+HTMLMedia.swift new file mode 100644 index 0000000..501118d --- /dev/null +++ b/Sources/Plot/API/Attribute+HTMLMedia.swift @@ -0,0 +1,167 @@ +/** +* Plot +* Copyright (c) John Sundell 2019 +* MIT license, see LICENSE file for details +*/ + +extension Attribute where Context: HTMLDimensionContext { + /// Assign a given width to the element. + /// - parameter width: The width to assign. + /// - Returns: The created attribute. + public static func width(_ width: Int) -> Attribute { + Attribute(name: "width", value: String(width)) + } + + /// Assign a given height to the element + /// - parameter height: The height to assign. + /// - Returns: The created attribute. + public static func height(_ height: Int) -> Attribute { + Attribute(name: "height", value: String(height)) + } +} + +extension Node where Context: HTMLStylableContext { + /// Assign inline CSS to the element, using its `style` attribute. + /// - parameter css: The CSS string to assign. + /// - Returns: The created node. + public static func style(_ css: String) -> Node { + .attribute(named: "style", value: css) + } +} + +// MARK: - Metadata + +extension Attribute where Context == HTML.MetaContext { + /// Assign an encoding to the element, using its `charset` attribute. + /// - parameter encoding: The encoding to assign. See `DocumentEncoding`. + /// - Returns: The created attribute. + public static func charset(_ encoding: DocumentEncoding) -> Attribute { + Attribute(name: "charset", value: encoding.rawValue) + } + + /// Assign a content string to the element. + /// - parameter content: The content value to assign. + /// - Returns: The created attribute. + public static func content(_ content: String) -> Attribute { + Attribute(name: "content", value: content) + } +} + +// MARK: - iFrames + +extension Attribute where Context == HTML.IFrameContext { + /// Assign whether the iframe should display a border or not. + /// - parameter isOn: Whether a border should be displayed. + /// - Returns: The created attribute. + public static func frameborder(_ isOn: Bool) -> Attribute { + Attribute(name: "frameborder", value: isOn ? "1" : "0") + } + + /// Assign what sort of features that the iframe should be allowed to access. + /// - parameter features: A list of feature names to allow. + /// - Returns: The created attribute. + public static func allow(_ features: String...) -> Attribute { + Attribute(name: "allow", value: features.joined(separator: "; ")) + } + + /// Assign whether to grant the iframe full screen capabilities. + /// - parameter allow: Whether the iframe should be allowed to go full screen. + /// - Returns: The created attribute. + public static func allowfullscreen(_ allow: Bool) -> Attribute { + allow ? Attribute(name: "allowfullscreen", value: nil, ignoreIfValueIsEmpty: false) : .empty + } +} + +// MARK: - Images + +extension Attribute where Context == HTML.ImageContext { + /// Assign an alternative text to the image. This is important both for + /// accessibility, and in case the referenced image can't be rendered. + /// - parameter text: The alternative text to use. + /// - Returns: The created attribute. + public static func alt(_ text: String) -> Attribute { + Attribute(name: "alt", value: text) + } +} + +// MARK: - Accessibility + +extension Node where Context: HTML.BodyContext { + /// Assign an accessibility label to the element, which is used by + /// assistive technologies to get a text representation of it. + /// - parameter label: The label to assign. + /// - Returns: The created node. + public static func ariaLabel(_ label: String) -> Node { + .attribute(named: "aria-label", value: label) + } + + /// Assign an accessibility attribute to an element, + /// to establish a parent -> child relationship + /// - parameter child: The child to assign to the parent + /// - Returns: The created node. + public static func ariaControls(_ child: String) -> Node { + .attribute(named: "aria-controls", value: child) + } + + /// Assign an accessibility attribute to an element, + /// which describes whether the element is expanded or not + /// - parameter isExpanded: Whether the element is expanded or not + /// - Returns: The created node. + public static func ariaExpanded(_ isExpanded: Bool) -> Node { + .attribute(named: "aria-expanded", value: isExpanded ? "true" : "false") + } + + /// Assign an accessibility attribute to an element, + /// which removes an element from the accessibility tree + /// - parameter isHidden: Whether the element is hidden or not + /// - Returns: The created node. + public static func ariaHidden(_ isHidden: Bool) -> Node { + .attribute(named: "aria-hidden", value: isHidden ? "true" : "false") + } +} + +// MARK: - Subresource Integrity + +extension Attribute where Context: HTMLIntegrityContext { + /// Assign a subresouce integrity hash to the element, using its `integrity` attribute. + /// - parameter hash: base64-encoded cryptographic hash + /// - Returns: The created attribute. + public static func integrity(_ hash: String) -> Attribute { + Attribute(name: "integrity", value: hash) + } +} + +extension Node where Context: HTMLIntegrityContext { + /// Assign a subresouce integrity hash to the element, using its `integrity` attribute. + /// - parameter hash: base64-encoded cryptographic hash + /// - Returns: The created node. + public static func integrity(_ hash: String) -> Node { + .attribute(named: "integrity", value: hash) + } +} + +// MARK: - Scripts + +extension Node where Context == HTML.ScriptContext { + /// Assign that the element's script should be loaded in `async` mode. + public static func async() -> Node { + .attribute(named: "async", value: nil, ignoreIfValueIsEmpty: false) + } + + /// Assign that the element's script should be loaded in `defer` mode. + public static func `defer`() -> Node { + .attribute(named: "defer", value: nil, ignoreIfValueIsEmpty: false) + } +} + +// MARK: - Javascript + +extension Node where Context: HTML.BodyContext { + /// Add a script to execute when the user clicks the current element. + /// - parameter script: The script to execute when the user clicks on the node. + /// Usually prefixed with `javascript:`. + /// - Returns: The created node. + public static func onclick(_ script: String) -> Node { + .attribute(named: "onclick", value: script) + } +} diff --git a/Sources/Plot/API/Attribute+Podcast.swift b/Sources/Plot/API/Attribute+Podcast.swift new file mode 100644 index 0000000..2c1bbe4 --- /dev/null +++ b/Sources/Plot/API/Attribute+Podcast.swift @@ -0,0 +1,28 @@ +/** +* Plot +* Copyright (c) John Sundell 2019 +* MIT license, see LICENSE file for details +*/ + +extension Attribute where Context == PodcastFeed.EnclosureContext { + /// Assign an URL from which the enclosure's file can be downloaded. + /// - parameter url: The URL to assign. + /// - Returns: The created attribute. + public static func url(_ url: URLRepresentable) -> Attribute { + Attribute(name: "url", value: url.string) + } + + /// Assign a length to the enclosure, in terms of its file size. + /// - parameter byteCount: The file's size in bytes. + /// - Returns: The created attribute. + public static func length(_ byteCount: Int) -> Attribute { + Attribute(name: "length", value: String(byteCount)) + } + + /// Assign a MIME type to the enclosure. + /// - parameter mimeType: The MIME type to assign. + /// - Returns: The created attribute. + public static func type(_ mimeType: String) -> Attribute { + Attribute(name: "type", value: mimeType) + } +} diff --git a/Sources/Plot/API/Attribute+XML.swift b/Sources/Plot/API/Attribute+XML.swift new file mode 100644 index 0000000..c13823a --- /dev/null +++ b/Sources/Plot/API/Attribute+XML.swift @@ -0,0 +1,21 @@ +/** +* Plot +* Copyright (c) John Sundell 2019 +* MIT license, see LICENSE file for details +*/ + +extension Attribute where Context == XML.DeclarationContext { + /// Declare the XML version used to define the document. + /// - parameter version: The XML document's version. + /// - Returns: The created attribute. + public static func version(_ version: Double) -> Attribute { + Attribute(name: "version", value: String(version)) + } + + /// Declare the encoding used to define the document's content. + /// - parameter encoding: The XML document's encoding. + /// - Returns: The created attribute. + public static func encoding(_ encoding: DocumentEncoding) -> Attribute { + Attribute(name: "encoding", value: encoding.rawValue) + } +} diff --git a/Sources/Plot/API/Attribute.swift b/Sources/Plot/API/Attribute.swift index 932b24e..40b3b70 100644 --- a/Sources/Plot/API/Attribute.swift +++ b/Sources/Plot/API/Attribute.swift @@ -11,63 +11,66 @@ import Foundation /// but rather use Plot's various DSL APIs to create them, for example by using /// the `id()` or `class()` modifier on an HTML element. public struct Attribute { - /// The name of the attribute - public var name: String - /// The attribute's value - public var value: String? - /// Whether the attribute's value should replace any existing one that has - /// already been added to a given element for the same attribute name. - public var replaceExisting: Bool - /// Whether the attribute should be completely ignored if it has no value. - public var ignoreIfValueIsEmpty: Bool + /// The name of the attribute + public var name: String + /// The attribute's value + public var value: String? + /// Whether the attribute's value should replace any existing one that has + /// already been added to a given element for the same attribute name. + public var replaceExisting: Bool + /// Whether the attribute should be completely ignored if it has no value. + public var ignoreIfValueIsEmpty: Bool - /// Create a new `Attribute` instance with a name and a value, and optionally - /// opt out of ignoring the attribute if its value is empty, and decide whether the - /// attribute should replace any existing one that's already been added to an element - /// for the same name. - public init(name: String, - value: String?, - replaceExisting: Bool = true, - ignoreIfValueIsEmpty: Bool = true) { - self.name = name - self.value = value - self.replaceExisting = replaceExisting - self.ignoreIfValueIsEmpty = ignoreIfValueIsEmpty - } + /// Create a new `Attribute` instance with a name and a value, and optionally + /// opt out of ignoring the attribute if its value is empty, and decide whether the + /// attribute should replace any existing one that's already been added to an element + /// for the same name. + public init( + name: String, + value: String?, + replaceExisting: Bool = true, + ignoreIfValueIsEmpty: Bool = true + ) { + self.name = name + self.value = value + self.replaceExisting = replaceExisting + self.ignoreIfValueIsEmpty = ignoreIfValueIsEmpty + } } -public extension Attribute { - /// Create a completely empty attribute, that's ignored during rendering. - /// This is useful in contexts where you need to return an `Attribute`, but - /// your logic determines that nothing should be added. - static var empty: Attribute { - Attribute(name: "", value: nil) - } +extension Attribute { + /// Create a completely empty attribute, that's ignored during rendering. + /// This is useful in contexts where you need to return an `Attribute`, but + /// your logic determines that nothing should be added. + public static var empty: Attribute { + Attribute(name: "", value: nil) + } - /// Create an attribute with a given name and value. This is the recommended - /// way of creating completely custom attributes, or ones that Plot does not - /// yet support, when within an attribute context. - static func attribute(named name: String, value: String?) -> Self { - Attribute(name: name, value: value) - } + /// Create an attribute with a given name and value. This is the recommended + /// way of creating completely custom attributes, or ones that Plot does not + /// yet support, when within an attribute context. + public static func attribute(named name: String, value: String?) -> Self { + Attribute(name: name, value: value) + } } -internal extension Attribute where Context == Any { - static func any(name: String, value: String) -> Attribute { - Attribute(name: name, value: value) - } +extension Attribute where Context == Any { + internal static func any(name: String, value: String) -> Attribute { + Attribute(name: name, value: value) + } } extension Attribute: NodeConvertible { - public var node: Node { .attribute(self) } + /// The node representation of this attribute. + public var node: Node { .attribute(self) } } extension Attribute: AnyAttribute { - func render() -> String { - guard let value = nonEmptyValue else { - return ignoreIfValueIsEmpty ? "" : name - } - - return "\(name)=\"\(value)\"" + internal func render() -> String { + guard let value = nonEmptyValue else { + return ignoreIfValueIsEmpty ? "" : name } + + return "\(name)=\"\(value)\"" + } } diff --git a/Sources/Plot/API/AudioPlayer.swift b/Sources/Plot/API/AudioPlayer.swift new file mode 100644 index 0000000..eaf5fc2 --- /dev/null +++ b/Sources/Plot/API/AudioPlayer.swift @@ -0,0 +1,82 @@ +/** +* Plot +* Copyright (c) John Sundell 2019 +* MIT license, see LICENSE file for details +*/ + +/// Component used to render an `