Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,17 @@ jobs:
run: swift test -c debug 2>&1 | xcbeautify --renderer github-actions
needs: [test_macOS, test_Catalyst, test_simulators]

SPM_linux:
name: "SPM: Linux, Swift 6.2"
runs-on: ubuntu-latest
timeout-minutes: 10
container: swift:6.2
steps:
- uses: actions/checkout@v4
- name: Test SPM
run: swift test -c debug
needs: [test_macOS, test_Catalyst, test_simulators]

### Carthage builds are no longer verified by CI (Carthage is in maintenance mode;
### compatibility is kept on a best-effort basis via the Xcode project).
### CocoaPods linting is reduced to macOS/iOS Release: the trunk becomes read-only
Expand Down Expand Up @@ -203,7 +214,7 @@ jobs:
name: CI Success
runs-on: ubuntu-latest
if: always()
needs: [lint_code, test_macOS, test_Catalyst, test_simulators, SPM, lint_cocoapods]
needs: [lint_code, test_macOS, test_Catalyst, test_simulators, SPM, SPM_linux, lint_cocoapods]
steps:
- name: Check all jobs succeeded
run: |
Expand Down
18 changes: 16 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,35 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [4.0.0] - 2026-08-18

### Added

- `idnaEncodedURL` / `idnaDecodedURL` on `String` and `Substring`: URL-aware variants that transform only the host of a URL-shaped string (`[scheme://][userinfo@]host[:port][/path?query#fragment]`), leaving every other component unchanged. Use these instead of applying `idnaEncoded` to a full URL.
- Linux support, verified by CI (`swift build` / `swift test` on the official Swift container).
- GitHub Releases are now created automatically when a version tag is pushed.
- `CI Success` aggregate check for branch protection.
- Community files: `CHANGELOG.md`, `CONTRIBUTING.md`, `SECURITY.md`, issue and PR templates.

### Changed

- **Breaking**: `Package.swift` requires swift-tools 5.9 (Xcode 15 or later for SPM consumers) and declares explicit platforms: macOS 10.13, iOS 12, tvOS 12, watchOS 4, visionOS 1.
- Product bundle identifiers renamed from `com.gumob.*` to `dev.futamura.*`, completing the account rename.
- CI runs on pushes and pull requests to `main` and `develop`, on current macOS runner images, resolving simulator destinations at runtime.
- API documentation migrated from jazzy to DocC, built and deployed to GitHub Pages by CI; the generated site is no longer tracked in the repository. New URL: <https://futamura.github.io/PunycodeSwift/documentation/punycode/>.
- Releasing is now a separate tag-triggered workflow that verifies the tag against the project version before publishing. Version tags are immutable.
- Development tooling: Ruby 3.4 / Bundler 2.7, gems updated to clear all outstanding Dependabot alerts.

### Fixed

- `punycodeDecoded` returned garbage characters for malformed input instead of nil: inputs ending mid-digit-sequence (e.g. `"y-z"`) and inputs decoding to C1 control characters (e.g. `"abcd"`) now return nil ([#78](https://github.com/futamura/PunycodeSwift/issues/78)).
- `punycodeDecoded` / `punycodeEncoded` could crash on adversarial input due to unchecked integer overflow; the RFC 3492 overflow checks are now implemented and such inputs return nil ([#78](https://github.com/futamura/PunycodeSwift/issues/78)).
- `idnaDecoded` now accepts an uppercase `XN--` ACE prefix (RFC 5890 defines it as case-insensitive).

### Changed

- **Breaking**: `idnaEncoded` now maps the hostname before encoding — NFKC compatibility folding (full-width forms, alternate dots), lowercasing, and NFC — so inputs like `"GOO.GL"` encode to `"goo.gl"` ([#4](https://github.com/futamura/PunycodeSwift/issues/4)). The full UTS #46 mapping table is intentionally not implemented. `punycodeEncoded` remains the raw RFC 3492 single-label transformation, now documented as such.

### Deprecated

- CocoaPods distribution ends when the trunk becomes read-only on 2026-12-02. Migrate to Swift Package Manager.
Expand Down Expand Up @@ -68,7 +82,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Initial release: Punycode (RFC 3492) encode/decode and IDNA encode/decode via `String` / `Substring` extensions.

[Unreleased]: https://github.com/futamura/PunycodeSwift/compare/3.0.0...HEAD
[4.0.0]: https://github.com/futamura/PunycodeSwift/compare/3.0.0...4.0.0
[3.0.0]: https://github.com/futamura/PunycodeSwift/compare/2.1.1...3.0.0
[2.1.1]: https://github.com/futamura/PunycodeSwift/compare/2.1.0...2.1.1
[2.1.0]: https://github.com/futamura/PunycodeSwift/compare/2.0.0...2.1.0
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Overview

PunycodeSwift — a pure Swift library (no dependencies) that encodes/decodes Punycode (RFC 3492) and IDNA hostnames via `String` / `Substring` extensions. Distributed primarily via SPM. Carthage compatibility is best-effort (not CI-verified); CocoaPods distribution (pod name: `Punycode`) ends when the trunk becomes read-only on 2026-12-02.
PunycodeSwift — a pure Swift library (no dependencies) that encodes/decodes Punycode (RFC 3492) and IDNA hostnames via `String` / `Substring` extensions. Runs on all Apple platforms and Linux. Distributed primarily via SPM. Carthage compatibility is best-effort (not CI-verified); CocoaPods distribution (pod name: `Punycode`) ends when the trunk becomes read-only on 2026-12-02.

## Commands

Expand Down Expand Up @@ -53,7 +53,7 @@ Tests live in `Tests/PunycodeTests.swift` (single XCTest file; SPM test target n

- Single source of truth for the version: `MARKETING_VERSION` in `Punycode.xcodeproj/project.pbxproj`. Fastlane and CI both read it. Never edit versions by hand — use `fastlane set_version` / `bump_version`, which also sync `Punycode.podspec`.
- Branch flow: work on `develop`, PR into `main`.
- CI (`.github/workflows/main.yml`) runs on push and pull request to `main`/`develop`: lint → per-platform xcodebuild tests (simulator devices resolved at runtime via `simctl`) → SPM → pod lib lint. It does not release. Carthage builds are not CI-verified.
- CI (`.github/workflows/main.yml`) runs on push and pull request to `main`/`develop`: lint → per-platform xcodebuild tests (simulator devices resolved at runtime via `simctl`) → SPM (macOS + Linux via the official Swift container) → pod lib lint. It does not release. Carthage builds are not CI-verified.
- Documentation (`.github/workflows/docs.yml`) builds the DocC site with `scripts/gen_docs.sh` (symbolgraph-extract with `-emit-extension-block-symbols` — required because the whole public API is extensions on String/Substring — then `docc convert`) and deploys it to GitHub Pages on every push to `main`. The site is not tracked in git; `docs/` no longer exists.
- After 2026-12-02 (CocoaPods trunk read-only): remove the `lint_cocoapods` job from `main.yml` and the pod push from `release.yml`.
- Releasing is a separate, explicit step: push a bare version tag (e.g. `3.0.1`) matching `MARKETING_VERSION`. `.github/workflows/release.yml` then verifies the tag against the project version, creates a GitHub Release (notes taken from the tag's `CHANGELOG.md` section, falling back to generated notes), and pushes to CocoaPods trunk. Use `./run.sh` → "Github - Update tag" to create the tag.
Expand Down
12 changes: 9 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
// swift-tools-version:5.1
// swift-tools-version:5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
name: "Punycode",
platforms: [
.macOS(.v10_13),
.iOS(.v12),
.tvOS(.v12),
.watchOS(.v4),
.visionOS(.v1),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing Comma Violation: Collection literals should not have trailing commas. (trailing_comma)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing Comma Violation: Collection literals should not have trailing commas. (trailing_comma)

],
products: [
.library(
name: "Punycode",
targets: ["Punycode"])

Check warning on line 18 in Package.swift

View workflow job for this annotation

GitHub Actions / Lint Code

[AddLines] add 1 line break
],
targets: [
.target(
name: "Punycode",
dependencies: [],
path: "Sources"),

Check warning on line 23 in Package.swift

View workflow job for this annotation

GitHub Actions / Lint Code

[AddLines] add 1 line break
.testTarget(
name: "PunycodeSwiftTests",
dependencies: ["Punycode"],
path: "Tests")
path: "Tests"),

Check warning on line 27 in Package.swift

View workflow job for this annotation

GitHub Actions / Lint Code

[AddLines] add 1 line break

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing Comma Violation: Collection literals should not have trailing commas. (trailing_comma)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing Comma Violation: Collection literals should not have trailing commas. (trailing_comma)

]
)
2 changes: 1 addition & 1 deletion Punycode.podspec
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Pod::Spec.new do |s|

s.name = "Punycode"
s.version = "3.0.0"
s.version = "4.0.0"
s.summary = "A Pure Swift library for encode and decode punycoded strings supporting iOS, macOS, tvOS, watchOS, and visionOS."
s.homepage = "https://github.com/futamura/PunycodeSwift"
s.license = { :type => "MIT", :file => "LICENSE" }
Expand Down
16 changes: 8 additions & 8 deletions Punycode.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -405,10 +405,10 @@
"@loader_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.13;
MARKETING_VERSION = 3.0.0;
MARKETING_VERSION = 4.0.0;
MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++";
MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20";
PRODUCT_BUNDLE_IDENTIFIER = com.gumob.Punycode;
PRODUCT_BUNDLE_IDENTIFIER = dev.futamura.Punycode;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = auto;
SKIP_INSTALL = YES;
Expand Down Expand Up @@ -450,10 +450,10 @@
"@loader_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.13;
MARKETING_VERSION = 3.0.0;
MARKETING_VERSION = 4.0.0;
MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++";
MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20";
PRODUCT_BUNDLE_IDENTIFIER = com.gumob.Punycode;
PRODUCT_BUNDLE_IDENTIFIER = dev.futamura.Punycode;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = auto;
SKIP_INSTALL = YES;
Expand All @@ -478,8 +478,8 @@
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MACOSX_DEPLOYMENT_TARGET = 10.13;
MARKETING_VERSION = 3.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.gumob.PunycodeTests;
MARKETING_VERSION = 4.0.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.futamura.PunycodeTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx watchos watchsimulator xros xrsimulator";
Expand All @@ -502,8 +502,8 @@
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MACOSX_DEPLOYMENT_TARGET = 10.13;
MARKETING_VERSION = 3.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.gumob.PunycodeTests;
MARKETING_VERSION = 4.0.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.futamura.PunycodeTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx watchos watchsimulator xros xrsimulator";
Expand Down
25 changes: 21 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
[![Swift Package Manager compatible](https://img.shields.io/badge/Swift_Package_Manager-compatible-orange)](https://github.com/futamura/PunycodeSwift)
[![Carthage compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg)](https://github.com/futamura/PunycodeSwift)
[![Cocoapods Version](https://img.shields.io/cocoapods/v/Punycode.svg)](https://cocoapods.org/pods/Punycode)
[![Cocoapods Platform](https://img.shields.io/cocoapods/p/Punycode.svg)](https://cocoadocs.org/docsets/Punycode)
[![Build](https://github.com/futamura/PunycodeSwift/actions/workflows/main.yml/badge.svg)](https://github.com/futamura/PunycodeSwift/actions/workflows/main.yml)
[![codecov](https://codecov.io/gh/futamura/PunycodeSwift/branch/main/graph/badge.svg)](https://codecov.io/gh/futamura/PunycodeSwift)
![Language](https://img.shields.io/badge/Language-Swift%205.0-orange.svg)
![Language](https://img.shields.io/badge/Language-Swift%205.9-orange.svg)
![License](https://img.shields.io/github/license/futamura/PunycodeSwift.svg)

# PunycodeSwift
Expand All @@ -21,7 +19,8 @@ Punycode is a representation of Unicode with the limited ASCII character subset
- tvOS 12.0 or later
- watchOS 4.0 or later
- visionOS 1.0 or later
- Swift 5.0 or later
- Linux
- Swift 5.9 or later (Xcode 15 or later) for Swift Package Manager

## Installation

Expand Down Expand Up @@ -116,8 +115,26 @@ sushi = sushi.idnaDecoded!
print(sushi) // "寿司"
```

### Encode and decode the host of a URL:

`idnaEncoded` expects a bare hostname. For a full URL, use `idnaEncodedURL` / `idnaDecodedURL`, which transform only the host and leave the scheme, userinfo, port, path, query, and fragment unchanged.

```swift
import Punycode

var url: String = "http://www.ラーメン.寿司.co.jp/メニュー"

url = url.idnaEncodedURL!
print(url) // http://www.xn--4dkp5a8a.xn--sprr0q.co.jp/メニュー

url = url.idnaDecodedURL!
print(url) // http://www.ラーメン.寿司.co.jp/メニュー
```

### Encode and decode Punycode directly:

`punycodeEncoded` / `punycodeDecoded` are the raw RFC 3492 transformation of a single label: no `xn--` prefix is added and dots are not treated as label separators.

```swift
import Punycode

Expand Down
70 changes: 66 additions & 4 deletions Sources/Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@

import Foundation

/// This extension provides methods for encoding and decoding strings using Punycode (RFC 3492)
/// and IDNA encoding. It allows for the conversion of Substring instances to their Punycode
/// This extension provides methods for encoding and decoding strings using Punycode (RFC 3492)
/// and IDNA encoding. It allows for the conversion of Substring instances to their Punycode
/// and IDNA representations, facilitating the handling of internationalized domain names.
public extension Substring {
/// Returns new string in punycode encoding (RFC 3492)
///
/// This is the raw RFC 3492 transformation of a single label: no `xn--`
/// prefix is added and dots are not treated as label separators, so
/// all-ASCII input yields a trailing delimiter (e.g. `"goo.gl"` becomes
/// `"goo.gl-"`). Use ``idnaEncoded`` to encode a hostname.
///
/// - Returns: Punycode encoded string or nil if the string can't be encoded
var punycodeEncoded: String? {
return Puny().encodePunycode(self)
Expand All @@ -24,6 +29,10 @@ public extension Substring {

/// Returns new string containing IDNA-encoded hostname
///
/// The hostname is mapped before encoding (NFKC compatibility folding,
/// lowercasing, NFC) and split on label separators, and only labels that
/// need it get the `xn--` ACE prefix.
///
/// - Returns: IDNA encoded hostname or nil if the string can't be encoded
var idnaEncoded: String? {
return Puny().encodeIDNA(self)
Expand All @@ -35,15 +44,42 @@ public extension Substring {
var idnaDecoded: String? {
return Puny().decodedIDNA(self)
}

/// Returns new string with the host portion of a URL IDNA-encoded
///
/// Unlike ``idnaEncoded``, which treats the whole string as a hostname,
/// this accepts a URL-shaped string and encodes only its host: scheme,
/// userinfo, port, path, query, and fragment are preserved unchanged.
///
/// - Returns: The URL with its host IDNA-encoded, or nil if the host can't be encoded
var idnaEncodedURL: String? {
return Puny().encodeIDNAURL(self)
}

/// Returns new string with the host portion of a URL decoded from IDNA representation
///
/// Unlike ``idnaDecoded``, which treats the whole string as a hostname,
/// this accepts a URL-shaped string and decodes only its host: scheme,
/// userinfo, port, path, query, and fragment are preserved unchanged.
///
/// - Returns: The URL with its host decoded, or nil if the host doesn't contain correct encoding
var idnaDecodedURL: String? {
return Puny().decodedIDNAURL(self)
}
}

/// This extension provides methods for encoding and decoding strings using Punycode (RFC 3492)
/// and IDNA encoding. It allows for the conversion of String instances to their Punycode
/// This extension provides methods for encoding and decoding strings using Punycode (RFC 3492)
/// and IDNA encoding. It allows for the conversion of String instances to their Punycode
/// and IDNA representations, facilitating the handling of internationalized domain names.
public extension String {

/// Returns new string in punycode encoding (RFC 3492)
///
/// This is the raw RFC 3492 transformation of a single label: no `xn--`
/// prefix is added and dots are not treated as label separators, so
/// all-ASCII input yields a trailing delimiter (e.g. `"goo.gl"` becomes
/// `"goo.gl-"`). Use ``idnaEncoded`` to encode a hostname.
///
/// - Returns: Punycode encoded string or nil if the string can't be encoded
var punycodeEncoded: String? {
return self[..<self.endIndex].punycodeEncoded
Expand All @@ -58,6 +94,10 @@ public extension String {

/// Returns new string containing IDNA-encoded hostname
///
/// The hostname is mapped before encoding (NFKC compatibility folding,
/// lowercasing, NFC) and split on label separators, and only labels that
/// need it get the `xn--` ACE prefix.
///
/// - Returns: IDNA encoded hostname or nil if the string can't be encoded
var idnaEncoded: String? {
return self[..<self.endIndex].idnaEncoded
Expand All @@ -69,4 +109,26 @@ public extension String {
var idnaDecoded: String? {
return self[..<self.endIndex].idnaDecoded
}

/// Returns new string with the host portion of a URL IDNA-encoded
///
/// Unlike ``idnaEncoded``, which treats the whole string as a hostname,
/// this accepts a URL-shaped string and encodes only its host: scheme,
/// userinfo, port, path, query, and fragment are preserved unchanged.
///
/// - Returns: The URL with its host IDNA-encoded, or nil if the host can't be encoded
var idnaEncodedURL: String? {
return self[..<self.endIndex].idnaEncodedURL
}

/// Returns new string with the host portion of a URL decoded from IDNA representation
///
/// Unlike ``idnaDecoded``, which treats the whole string as a hostname,
/// this accepts a URL-shaped string and decodes only its host: scheme,
/// userinfo, port, path, query, and fragment are preserved unchanged.
///
/// - Returns: The URL with its host decoded, or nil if the host doesn't contain correct encoding
var idnaDecodedURL: String? {
return self[..<self.endIndex].idnaDecodedURL
}
}
14 changes: 8 additions & 6 deletions Sources/Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import Foundation

/// Returns the last index of the specified element in the substring.
///
///
/// - Parameter element: The character to search for.
/// - Returns: The index of the last occurrence of the character, or nil if the character is not found.
extension Substring {
Expand All @@ -21,13 +21,15 @@ extension Substring {
}
}

/// A computed property that checks if the Unicode scalar is valid.
///
/// A computed property that checks if the Unicode scalar is acceptable in
/// punycode-encoded text.
///
/// - Returns: A boolean value indicating whether the Unicode scalar is valid.
/// A Unicode scalar is considered valid if its value is less than 0xD880
/// or within the range of 0xE000 to 0x1FFFFF.
/// C1 control characters (U+0080...U+009F) never appear in legitimate
/// punycode input or output; surrogates and out-of-range values are already
/// unrepresentable in `Unicode.Scalar`.
extension UnicodeScalar {
internal var isValid: Bool {
return value < 0xD880 || (value >= 0xE000 && value <= 0x1FFFFF)
return !(0x80...0x9F).contains(value)
}
}
Loading
Loading