Skip to content

Commit 7f9243d

Browse files
committed
feat: initial implementation of WHATWG URL encoding
- Implements WHATWG URL Standard for application/x-www-form-urlencoded - serialize() - Convert name-value pairs to URL-encoded string - parse() - Parse URL-encoded string to name-value pairs - percentEncode() - WHATWG-compliant percent encoding (space → +) - percentDecode() - Handles + as space and UTF-8 multi-byte characters - Character set: Only alphanumeric + *-._ unencoded - Comprehensive test suite (19 tests) - README with examples and Foundation comparison - CI/CD with swiftlint and swift-format - Swift 6.0 strict concurrency support
0 parents  commit 7f9243d

File tree

10 files changed

+821
-0
lines changed

10 files changed

+821
-0
lines changed

.github/workflows/ci.yml

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
branches:
9+
- main
10+
workflow_dispatch:
11+
12+
concurrency:
13+
group: ci-${{ github.ref }}
14+
cancel-in-progress: true
15+
16+
jobs:
17+
# Primary development workflow: Latest Swift on macOS with debug build
18+
macos-latest:
19+
name: macOS (Swift latest, debug)
20+
runs-on: macos-latest
21+
steps:
22+
- uses: actions/checkout@v5
23+
24+
- name: Print Swift version
25+
run: swift --version
26+
27+
- name: Cache Swift packages
28+
uses: actions/cache@v4
29+
with:
30+
path: .build
31+
key: ${{ runner.os }}-spm-${{ hashFiles('Package.swift') }}
32+
restore-keys: |
33+
${{ runner.os }}-spm-
34+
35+
# Note: swift test builds automatically, no separate build step needed
36+
- name: Test
37+
run: swift test -c debug
38+
39+
- name: Validate Package.swift
40+
run: swift package dump-package
41+
42+
- name: Run README verification tests
43+
run: swift test --filter ReadmeVerificationTests
44+
45+
- name: Check for API breaking changes
46+
run: swift package diagnose-api-breaking-changes --breakage-allowlist-path .swift-api-breakage-allowlist || true
47+
continue-on-error: true
48+
49+
# Production validation: Latest Swift on Linux with release build
50+
linux-latest:
51+
name: Ubuntu (Swift latest, release)
52+
runs-on: ubuntu-latest
53+
container: swift:latest
54+
steps:
55+
- uses: actions/checkout@v5
56+
57+
- name: Cache Swift packages
58+
uses: actions/cache@v4
59+
with:
60+
path: .build
61+
key: ${{ runner.os }}-spm-${{ hashFiles('Package.swift') }}
62+
restore-keys: ${{ runner.os }}-spm-
63+
64+
# Note: swift test builds automatically in release mode
65+
- name: Test (release)
66+
run: swift test -c release
67+
68+
# Compatibility check: Minimum supported Swift version (6.0)
69+
# Note: Swift Testing framework requires Swift 6.0+
70+
linux-compat:
71+
name: Ubuntu (Swift 6.0, compatibility)
72+
runs-on: ubuntu-latest
73+
container: swift:6.0
74+
steps:
75+
- uses: actions/checkout@v5
76+
77+
- name: Cache Swift packages
78+
uses: actions/cache@v4
79+
with:
80+
path: .build
81+
key: ${{ runner.os }}-swift60-spm-${{ hashFiles('Package.swift') }}
82+
restore-keys: ${{ runner.os }}-swift60-spm-
83+
84+
# Note: swift test builds automatically
85+
- name: Test (Swift 6.0)
86+
run: swift test -c release

.github/workflows/swift-format.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
name: Swift Format
2+
on:
3+
push:
4+
branches: [main]
5+
6+
jobs:
7+
format:
8+
runs-on: macos-latest
9+
steps:
10+
- uses: actions/checkout@v5
11+
- name: Install swift-format
12+
run: brew install swift-format
13+
- name: Format code
14+
run: swift-format format --recursive --in-place Sources Tests
15+
- name: Commit changes
16+
uses: stefanzweifel/git-auto-commit-action@v7
17+
with:
18+
commit_message: "Run swift-format"

.github/workflows/swiftlint.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
name: SwiftLint
2+
on:
3+
pull_request:
4+
branches: [main]
5+
6+
jobs:
7+
lint:
8+
runs-on: macos-latest
9+
steps:
10+
- uses: actions/checkout@v5
11+
- name: Install SwiftLint
12+
run: brew install swiftlint
13+
- name: SwiftLint
14+
run: swiftlint lint
15+
continue-on-error: true

.gitignore

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Swift
2+
.build/
3+
.swiftpm/
4+
Package.resolved
5+
6+
# Environment files
7+
.env*
8+
9+
# Xcode
10+
*.xcodeproj
11+
*.xcworkspace
12+
*.xcuserdata
13+
DerivedData/
14+
15+
# IDEs
16+
.vscode/
17+
.idea/
18+
*.swp
19+
*.swo
20+
*~
21+
22+
# Generated by MacOS
23+
.DS_Store
24+
25+
# Generated by Windows
26+
Thumbs.db
27+
28+
# Generated by Linux
29+
*~
30+
31+
# Log files
32+
*.log
33+
34+
# AI assistant files
35+
.claude/
36+
CLAUDE.md
37+
CLAUDE.MD
38+
39+
# Documentation (opt-in for top-level .md files only)
40+
/*.md
41+
!README.md
42+
!LICENSE.md
43+
!CHANGELOG.md
44+
!CONTRIBUTING.md
45+
!CODE_OF_CONDUCT.md
46+
!SECURITY.md
47+
48+
# Temporary files
49+
*.tmp
50+
*.temp
51+
52+
.swift-version

.swift-format

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{
2+
"version": 1,
3+
"lineLength": 100,
4+
"indentation": {
5+
"spaces": 4
6+
},
7+
"tabWidth": 8,
8+
"maximumBlankLines": 1,
9+
"respectsExistingLineBreaks": true,
10+
"lineBreakBeforeControlFlowKeywords": false,
11+
"lineBreakBeforeEachArgument": true,
12+
"lineBreakBeforeEachGenericRequirement": false,
13+
"prioritizeKeepingFunctionOutputTogether": true,
14+
"indentConditionalCompilationBlocks": true,
15+
"lineBreakAroundMultilineExpressionChainComponents": false,
16+
"fileScopedDeclarationPrivacy": {
17+
"accessLevel": "private"
18+
},
19+
"rules": {
20+
"AllPublicDeclarationsHaveDocumentation": false,
21+
"AlwaysUseLowerCamelCase": true,
22+
"AmbiguousTrailingClosureOverload": true,
23+
"BeginDocumentationCommentWithOneLineSummary": false,
24+
"DoNotUseSemicolons": true,
25+
"DontRepeatTypeInStaticProperties": true,
26+
"FileScopedDeclarationPrivacy": true,
27+
"FullyIndirectEnum": true,
28+
"GroupNumericLiterals": true,
29+
"IdentifiersMustBeASCII": true,
30+
"NeverForceUnwrap": false,
31+
"NeverUseForceTry": false,
32+
"NeverUseImplicitlyUnwrappedOptionals": false,
33+
"NoAccessLevelOnExtensionDeclaration": true,
34+
"NoBlockComments": true,
35+
"NoCasesWithOnlyFallthrough": true,
36+
"NoEmptyTrailingClosureParentheses": true,
37+
"NoLabelsInCasePatterns": true,
38+
"NoLeadingUnderscores": false,
39+
"NoParensAroundConditions": true,
40+
"NoVoidReturnOnFunctionSignature": true,
41+
"OneCasePerLine": true,
42+
"OneVariableDeclarationPerLine": true,
43+
"OnlyOneTrailingClosureArgument": true,
44+
"OrderedImports": true,
45+
"ReturnVoidInsteadOfEmptyTuple": true,
46+
"UseLetInEveryBoundCaseVariable": true,
47+
"UseShorthandTypeNames": true,
48+
"UseSingleLinePropertyGetter": true,
49+
"UseSynthesizedInitializer": true,
50+
"UseTripleSlashForDocumentationComments": true,
51+
"UseWhereClausesInForLoops": false,
52+
"ValidateDocumentationComments": false
53+
}
54+
}

.swiftlint.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
disabled_rules:
2+
- line_length
3+
- function_body_length
4+
- type_body_length
5+
- file_length
6+
- redundant_discardable_let
7+
- trailing_comma
8+
- type_name
9+
- identifier_name
10+
opt_in_rules:
11+
- explicit_init
12+
included:
13+
- Sources
14+
- Tests
15+
excluded:
16+
- .build

Package.swift

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// swift-tools-version: 6.0
2+
3+
import PackageDescription
4+
5+
let package = Package(
6+
name: "swift-whatwg-url-encoding",
7+
platforms: [
8+
.macOS(.v14),
9+
.iOS(.v17),
10+
.tvOS(.v17),
11+
.watchOS(.v10)
12+
],
13+
products: [
14+
.library(
15+
name: "WHATWG URL Encoding",
16+
targets: ["WHATWG URL Encoding"]
17+
)
18+
],
19+
dependencies: [],
20+
targets: [
21+
.target(
22+
name: "WHATWG URL Encoding",
23+
dependencies: []
24+
),
25+
.testTarget(
26+
name: "WHATWG URL Encoding Tests",
27+
dependencies: ["WHATWG URL Encoding"]
28+
)
29+
]
30+
)
31+
32+
for target in package.targets {
33+
target.swiftSettings?.append(
34+
contentsOf: [
35+
.enableUpcomingFeature("MemberImportVisibility")
36+
]
37+
)
38+
}

README.md

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# swift-whatwg-url-encoding
2+
3+
WHATWG URL Standard implementation for `application/x-www-form-urlencoded` encoding in Swift.
4+
5+
## Overview
6+
7+
This package implements the [WHATWG URL Living Standard](https://url.spec.whatwg.org/#application/x-www-form-urlencoded) specification for `application/x-www-form-urlencoded` encoding and decoding.
8+
9+
The WHATWG URL Standard defines the precise character set and encoding rules for URL form encoding, which differs from Foundation's URL encoding in key ways:
10+
11+
- **Space encoding**: WHATWG uses `+`, Foundation uses `%20`
12+
- **Character set**: WHATWG only leaves alphanumeric + `*-._` unencoded
13+
- **Specification compliance**: Exact implementation of the WHATWG algorithm
14+
15+
## Installation
16+
17+
### Swift Package Manager
18+
19+
Add to your `Package.swift`:
20+
21+
```swift
22+
dependencies: [
23+
.package(url: "https://github.com/swift-standards/swift-whatwg-url-encoding", from: "0.1.0")
24+
]
25+
```
26+
27+
Then add the dependency to your target:
28+
29+
```swift
30+
.target(
31+
name: "YourTarget",
32+
dependencies: [
33+
.product(name: "WHATWG URL Encoding", package: "swift-whatwg-url-encoding")
34+
]
35+
)
36+
```
37+
38+
## Usage
39+
40+
### Serialize to application/x-www-form-urlencoded
41+
42+
```swift
43+
import WHATWG_URL_Encoding
44+
45+
let encoded = WHATWG_URL_Encoding.serialize([
46+
("name", "John Doe"),
47+
("email", "[email protected]")
48+
])
49+
// Result: "name=John+Doe&email=john%40example.com"
50+
```
51+
52+
### Parse application/x-www-form-urlencoded
53+
54+
```swift
55+
let pairs = WHATWG_URL_Encoding.parse("name=John+Doe&email=john%40example.com")
56+
// Result: [("name", "John Doe"), ("email", "[email protected]")]
57+
```
58+
59+
### Percent Encoding
60+
61+
```swift
62+
let encoded = WHATWG_URL_Encoding.percentEncode("Hello World!", spaceAsPlus: true)
63+
// Result: "Hello+World%21"
64+
```
65+
66+
### Percent Decoding
67+
68+
```swift
69+
let decoded = WHATWG_URL_Encoding.percentDecode("Hello+World%21", plusAsSpace: true)
70+
// Result: "Hello World!"
71+
```
72+
73+
## WHATWG Character Set
74+
75+
According to the WHATWG URL Standard, only the following characters are left unencoded:
76+
77+
- ASCII alphanumeric (`a-z`, `A-Z`, `0-9`)
78+
- Asterisk (`*`)
79+
- Hyphen (`-`)
80+
- Period (`.`)
81+
- Underscore (`_`)
82+
83+
All other characters are percent-encoded. Space (0x20) is encoded as `+` when `spaceAsPlus` is `true`.
84+
85+
## Difference from Foundation
86+
87+
Foundation's `URLComponents` and related APIs use a different encoding scheme:
88+
89+
```swift
90+
// WHATWG (this package)
91+
"Hello World!" "Hello+World%21"
92+
93+
// Foundation
94+
"Hello World!" "Hello%20World!" // Different space encoding
95+
```
96+
97+
Additionally, Foundation's URL encoding is more permissive with special characters, while WHATWG strictly limits unencoded characters to the set above.
98+
99+
## Reference
100+
101+
- [WHATWG URL Living Standard - application/x-www-form-urlencoded](https://url.spec.whatwg.org/#application/x-www-form-urlencoded)
102+
103+
## Requirements
104+
105+
- Swift 6.0+
106+
- macOS 14.0+, iOS 17.0+, tvOS 17.0+, watchOS 10.0+
107+
108+
## License
109+
110+
MIT

0 commit comments

Comments
 (0)