From 004edfd62a249590f6867ae8b5c2192f7a804281 Mon Sep 17 00:00:00 2001 From: Chris Chrisostomou Date: Wed, 30 Sep 2026 11:17:10 +0100 Subject: [PATCH] added trailing dash exemption as thunderbird strips it from autolinks --- CHANGELOG.md | 3 ++- CLAUDE.md | 2 +- README.md | 15 ++++++------- src/Alphabet/AbstractUrlSafeAlphabet.php | 17 ++++++++------- .../Alphabet/AbstractUrlSafeAlphabetTest.php | 21 ++++++++++++++++--- tests/Unit/Alphabet/AlphanumericTest.php | 3 ++- tests/Unit/Alphabet/Base64UrlTest.php | 3 ++- .../Alphabet/LowercaseAlphanumericTest.php | 3 ++- tests/Unit/Alphabet/UrlSafeTest.php | 3 ++- 9 files changed, 47 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bd3f8b..3715cdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `AbstractAlphabet` base class: one symbol per UTF-8 character (multibyte characters and emoji included); validates at least two distinct characters; override `isReserved()` to exclude outputs. - `AbstractUrlSafeAlphabet` base class: restricts characters to the RFC 3986 unreserved set and reserves any - output ending in `.` (including `.` and `..`); extend it for custom URL-safe subsets or extra `isReserved()` rules. + output ending in `.` (including `.` and `..`) or `-`, since email linkifiers drop either from the end of a URL; + extend it for custom URL-safe subsets or extra `isReserved()` rules. - URL-safe presets `UrlSafe` (default, full 66-char unreserved set), `Alphanumeric` (62), `LowercaseAlphanumeric` (36) and `Base64Url` (64), each a fixed-set class extending `AbstractUrlSafeAlphabet`. - `Generic` alphabet: any UTF-8 character set passed to the constructor, no URL-safety check, reserves nothing. diff --git a/CLAUDE.md b/CLAUDE.md index 30730a1..c418e70 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,7 @@ Supporting pieces, each in its own namespace under `src/`: the escalation ceiling for auto-increment. - **`Alphabet/`**: `AlphabetInterface` (`size()`, `characterAt()`, `isReserved()`) with `AbstractAlphabet` doing UTF-8 splitting and validation. `AbstractUrlSafeAlphabet` adds the - RFC 3986 unreserved-set check and reserves any output ending in `.`. The four presets + RFC 3986 unreserved-set check and reserves any output ending in `.` or `-`. The four presets (`UrlSafe`, `Alphanumeric`, `LowercaseAlphanumeric`, `Base64Url`) are final classes with a `CHARACTERS` constant; `Generic` takes any string and reserves nothing. Custom reservation rules extend `AbstractUrlSafeAlphabet`, not a preset. diff --git a/README.md b/README.md index d54b71f..26f9e4b 100644 --- a/README.md +++ b/README.md @@ -245,14 +245,15 @@ All alphabets live in the `Chrismou\StringMint\Alphabet` namespace. | `new LowercaseAlphanumeric()` | a-z 0-9 | 36 | Safe for case-insensitive DB collations | | `new Base64Url()` | A-Z a-z 0-9 `-_` | 64 | Base64url character set | | `new Generic($chars)` | any UTF-8 characters | varies | No URL-safety check, reserves nothing | -| extend `AbstractUrlSafeAlphabet` | your subset of `-._~` A-Z a-z 0-9 | varies | Keeps the URL-safety check and trailing-dot guard | +| extend `AbstractUrlSafeAlphabet` | your subset of `-._~` A-Z a-z 0-9 | varies | Keeps the URL-safety check and trailing-character guard | | extend `AbstractAlphabet` | any UTF-8 characters | varies | See "Custom alphabets" below | -**Trailing-dot guard:** Every URL-safe alphabet (`UrlSafe`, `Alphanumeric`, `LowercaseAlphanumeric`, `Base64Url`, -and anything extending `AbstractUrlSafeAlphabet`) reserves any output ending in `.`. That covers `.` and `..`, -which every URL parser normalises away so they would silently collide with other paths, and strings like `abc.`, -where chat clients and email linkifiers drop the trailing dot. `Generic` and -custom alphabets built on `AbstractAlphabet` reserve nothing unless they override `isReserved()`. +**Trailing-character guard:** Every URL-safe alphabet (`UrlSafe`, `Alphanumeric`, `LowercaseAlphanumeric`, +`Base64Url`, and anything extending `AbstractUrlSafeAlphabet`) reserves any output ending in `.` or `-`. The dot +covers `.` and `..`, which every URL parser normalises away so they would silently collide with other paths, and +strings like `abc.`, where chat clients and email linkifiers drop the trailing dot. The dash covers strings like +`abc-`, where some email clients stop the link before the dash and break the URL. `Generic` and custom alphabets +built on `AbstractAlphabet` reserve nothing unless they override `isReserved()`. **Case-insensitive collations:** MySQL `utf8mb4_general_ci` and SQL Server defaults treat `abc` and `ABC` as equal. A unique index will reject strings the generator considers distinct. Use @@ -305,7 +306,7 @@ Each UTF-8 character is one symbol, so accented letters, Greek or emoji work as- string from that alphabet is 8 bytes. Size your column accordingly. To keep the URL-safe character set and only exclude a few outputs, extend `AbstractUrlSafeAlphabet` instead - -you keep the RFC 3986 validation and the trailing-dot guard: +you keep the RFC 3986 validation and the trailing-character guard: ```php use Chrismou\StringMint\Alphabet\AbstractUrlSafeAlphabet; diff --git a/src/Alphabet/AbstractUrlSafeAlphabet.php b/src/Alphabet/AbstractUrlSafeAlphabet.php index ac5b1ef..7099a40 100644 --- a/src/Alphabet/AbstractUrlSafeAlphabet.php +++ b/src/Alphabet/AbstractUrlSafeAlphabet.php @@ -10,11 +10,13 @@ * Base class for alphabets restricted to the RFC 3986 unreserved set (A-Z a-z 0-9 - . _ ~). * * Validates every character against the unreserved set on top of AbstractAlphabet's checks, and reserves any - * output ending in ".": that covers "." and "..", which every URL parser normalises away, and strings like - * "abc.", where the trailing dot is dropped by chat clients and email linkifiers. Nothing else is reserved. + * output ending in "." or "-": "." covers "." and "..", which every URL parser normalises away, and strings + * like "abc.", where the trailing dot is dropped by chat clients and email linkifiers. "-" covers strings like + * "abc-", where some email clients stop the link before the dash and break the URL. Nothing else is reserved. * * Extend this class to build a URL-safe alphabet from your own subset of the unreserved characters, or to - * exclude more outputs by overriding isReserved() (call parent::isReserved() to keep the trailing-dot guard). + * exclude more outputs by overriding isReserved() (call parent::isReserved() to keep the trailing-character + * guard). */ abstract class AbstractUrlSafeAlphabet extends AbstractAlphabet { @@ -31,14 +33,15 @@ public function __construct(string $characters) } /** - * Returns true for any candidate ending in ".", which covers the "." and ".." path-segment hazards and - * strings that auto-linkers would truncate. + * Returns true for any candidate ending in "." or "-": "." covers the "." and ".." path-segment hazards, + * and both characters are dropped from the end of a URL by chat clients and email linkifiers. * - * Subclasses that extend this method should call parent::isReserved() to preserve the trailing-dot guard. + * Subclasses that extend this method should call parent::isReserved() to preserve the trailing-character + * guard. */ public function isReserved(string $candidate): bool { - return str_ends_with($candidate, '.'); + return str_ends_with($candidate, '.') || str_ends_with($candidate, '-'); } /** diff --git a/tests/Unit/Alphabet/AbstractUrlSafeAlphabetTest.php b/tests/Unit/Alphabet/AbstractUrlSafeAlphabetTest.php index 3256e60..60183df 100644 --- a/tests/Unit/Alphabet/AbstractUrlSafeAlphabetTest.php +++ b/tests/Unit/Alphabet/AbstractUrlSafeAlphabetTest.php @@ -84,6 +84,16 @@ public function testIsReservedReturnsTrueForAnyTrailingDot(): void $this->assertTrue($alphabet->isReserved('abc-1.')); } + #[Test] + public function testIsReservedReturnsTrueForAnyTrailingDash(): void + { + $alphabet = new class (AbstractUrlSafeAlphabet::RFC3986_UNRESERVED) extends AbstractUrlSafeAlphabet {}; + $this->assertTrue($alphabet->isReserved('-')); + $this->assertTrue($alphabet->isReserved('--')); + $this->assertTrue($alphabet->isReserved('a-')); + $this->assertTrue($alphabet->isReserved('abc.1-')); + } + #[Test] public function testIsReservedReturnsFalseForOtherStrings(): void { @@ -91,6 +101,10 @@ public function testIsReservedReturnsFalseForOtherStrings(): void $this->assertFalse($alphabet->isReserved('a')); $this->assertFalse($alphabet->isReserved('.a')); $this->assertFalse($alphabet->isReserved('a.b')); + $this->assertFalse($alphabet->isReserved('-a')); + $this->assertFalse($alphabet->isReserved('a-b')); + $this->assertFalse($alphabet->isReserved('a_')); + $this->assertFalse($alphabet->isReserved('a~')); $this->assertFalse($alphabet->isReserved('')); } @@ -102,14 +116,15 @@ public function testSubclassCanOverrideIsReservedWhileKeepingUnreservedValidatio $extended = new class (AbstractUrlSafeAlphabet::RFC3986_UNRESERVED) extends AbstractUrlSafeAlphabet { public function isReserved(string $candidate): bool { - return parent::isReserved($candidate) || str_starts_with($candidate, '-'); + return parent::isReserved($candidate) || str_starts_with($candidate, '_'); } }; $this->assertSame(66, $extended->size()); $this->assertTrue($extended->isReserved('.')); - $this->assertTrue($extended->isReserved('-a')); - $this->assertFalse($extended->isReserved('a-')); + $this->assertTrue($extended->isReserved('a-')); + $this->assertTrue($extended->isReserved('_a')); + $this->assertFalse($extended->isReserved('a_')); } #[Test] diff --git a/tests/Unit/Alphabet/AlphanumericTest.php b/tests/Unit/Alphabet/AlphanumericTest.php index 0642df9..e759735 100644 --- a/tests/Unit/Alphabet/AlphanumericTest.php +++ b/tests/Unit/Alphabet/AlphanumericTest.php @@ -54,10 +54,11 @@ public function testCharactersAreASubsetOfTheUnreservedSet(): void } #[Test] - public function testInheritsTheTrailingDotGuard(): void + public function testInheritsTheTrailingCharacterGuard(): void { $alphabet = new Alphanumeric(); $this->assertTrue($alphabet->isReserved('a.')); + $this->assertTrue($alphabet->isReserved('a-')); $this->assertFalse($alphabet->isReserved('a')); } } diff --git a/tests/Unit/Alphabet/Base64UrlTest.php b/tests/Unit/Alphabet/Base64UrlTest.php index dbe0944..9d9a0ea 100644 --- a/tests/Unit/Alphabet/Base64UrlTest.php +++ b/tests/Unit/Alphabet/Base64UrlTest.php @@ -54,10 +54,11 @@ public function testCharactersAreASubsetOfTheUnreservedSet(): void } #[Test] - public function testInheritsTheTrailingDotGuard(): void + public function testInheritsTheTrailingCharacterGuard(): void { $alphabet = new Base64Url(); $this->assertTrue($alphabet->isReserved('a.')); + $this->assertTrue($alphabet->isReserved('a-')); $this->assertFalse($alphabet->isReserved('a')); } } diff --git a/tests/Unit/Alphabet/LowercaseAlphanumericTest.php b/tests/Unit/Alphabet/LowercaseAlphanumericTest.php index 0509c41..992f5f3 100644 --- a/tests/Unit/Alphabet/LowercaseAlphanumericTest.php +++ b/tests/Unit/Alphabet/LowercaseAlphanumericTest.php @@ -53,10 +53,11 @@ public function testCharactersAreASubsetOfTheUnreservedSet(): void } #[Test] - public function testInheritsTheTrailingDotGuard(): void + public function testInheritsTheTrailingCharacterGuard(): void { $alphabet = new LowercaseAlphanumeric(); $this->assertTrue($alphabet->isReserved('a.')); + $this->assertTrue($alphabet->isReserved('a-')); $this->assertFalse($alphabet->isReserved('a')); } } diff --git a/tests/Unit/Alphabet/UrlSafeTest.php b/tests/Unit/Alphabet/UrlSafeTest.php index 990b92e..4af6902 100644 --- a/tests/Unit/Alphabet/UrlSafeTest.php +++ b/tests/Unit/Alphabet/UrlSafeTest.php @@ -52,10 +52,11 @@ public function testCharactersAreASubsetOfTheUnreservedSet(): void } #[Test] - public function testInheritsTheTrailingDotGuard(): void + public function testInheritsTheTrailingCharacterGuard(): void { $alphabet = new UrlSafe(); $this->assertTrue($alphabet->isReserved('a.')); + $this->assertTrue($alphabet->isReserved('a-')); $this->assertFalse($alphabet->isReserved('a')); } }