From 3f81c79fbbbad139e59a59128a61efe10e0ed518 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 14 Aug 2026 20:19:53 +1200 Subject: [PATCH 1/2] Preserve relative URL path semantics. --- lib/protocol/url/path.rb | 10 ++++++++++ releases.md | 4 ++++ test/protocol/url/path.rb | 12 ++++++++++++ test/protocol/url/relative.rb | 6 ++++++ 4 files changed, 32 insertions(+) diff --git a/lib/protocol/url/path.rb b/lib/protocol/url/path.rb index 79ec8f7..6ad68a7 100644 --- a/lib/protocol/url/path.rb +++ b/lib/protocol/url/path.rb @@ -372,12 +372,22 @@ def relative(from) common_length = i + 1 end + # Preserve the final segment when the target names the containing directory as a file: + if common_length > 0 && common_length == target_segments.size && target_segments.last != "" + common_length -= 1 + end + # Calculate how many levels to go up up_levels = from_segments.size - common_length # Build the relative path segments relative_segments = [".."] * up_levels + target_segments[common_length..-1] + # An empty reference identifies the current document, so identify the current directory explicitly: + if relative_segments == [""] + relative_segments = [".", ""] + end + return Path.new(nil, relative_segments) end diff --git a/releases.md b/releases.md index 6145a2b..a64ed54 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - Preserve directory and file semantics when generating relative URL paths. + ## v0.15.0 - Add `Protocol::URL::Relative#relative_to` for expressing root-relative URLs relative to a base path. diff --git a/test/protocol/url/path.rb b/test/protocol/url/path.rb index c5cf35b..4387050 100644 --- a/test/protocol/url/path.rb +++ b/test/protocol/url/path.rb @@ -509,6 +509,18 @@ expect(Protocol::URL::Path.relative("/docs/", "/docs/api/reference.html")).to be == "../" end + it "identifies the current directory explicitly" do + expect(Protocol::URL::Path.relative("/docs/", "/docs/index.html")).to be == "./" + end + + it "calculates relative path to the root directory" do + expect(Protocol::URL::Path.relative("/", "/index.html")).to be == "./" + end + + it "distinguishes a file from its containing directory" do + expect(Protocol::URL::Path.relative("/docs", "/docs/index.html")).to be == "../docs" + end + it "calculates relative path with multiple levels up" do expect(Protocol::URL::Path.relative("/a/file.txt", "/x/y/z/")).to be == "../../../a/file.txt" end diff --git a/test/protocol/url/relative.rb b/test/protocol/url/relative.rb index c292f27..62b60b8 100644 --- a/test/protocol/url/relative.rb +++ b/test/protocol/url/relative.rb @@ -218,6 +218,12 @@ expect(url.relative_to("/index").path).to be == Protocol::URL::Path["files/a%2Fb"] end + it "identifies the root directory explicitly" do + url = Protocol::URL::Relative.new("/", "q=ruby", "examples") + + expect(url.relative_to("/index").to_s).to be == "./?q=ruby#examples" + end + it "returns already-relative URLs unchanged" do url = Protocol::URL::Relative.new("../guide", "q=ruby", "examples") From e8623e8415c7435f1d3d141237ab67a0352c0ecf Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 14 Aug 2026 20:31:46 +1200 Subject: [PATCH 2/2] Disambiguate relative paths containing colons. --- lib/protocol/url/path.rb | 3 +++ test/protocol/url/path.rb | 4 ++++ test/protocol/url/relative.rb | 8 ++++++++ 3 files changed, 15 insertions(+) diff --git a/lib/protocol/url/path.rb b/lib/protocol/url/path.rb index 6ad68a7..384188b 100644 --- a/lib/protocol/url/path.rb +++ b/lib/protocol/url/path.rb @@ -386,6 +386,9 @@ def relative(from) # An empty reference identifies the current document, so identify the current directory explicitly: if relative_segments == [""] relative_segments = [".", ""] + elsif relative_segments.first&.include?(":") + # A colon in the first segment would be interpreted as a URI scheme: + relative_segments.unshift(".") end return Path.new(nil, relative_segments) diff --git a/test/protocol/url/path.rb b/test/protocol/url/path.rb index 4387050..7507eb7 100644 --- a/test/protocol/url/path.rb +++ b/test/protocol/url/path.rb @@ -521,6 +521,10 @@ expect(Protocol::URL::Path.relative("/docs", "/docs/index.html")).to be == "../docs" end + it "disambiguates a colon in the first segment" do + expect(Protocol::URL::Path.relative("/docs/this:that", "/docs/index.html")).to be == "./this:that" + end + it "calculates relative path with multiple levels up" do expect(Protocol::URL::Path.relative("/a/file.txt", "/x/y/z/")).to be == "../../../a/file.txt" end diff --git a/test/protocol/url/relative.rb b/test/protocol/url/relative.rb index 62b60b8..c3a97ee 100644 --- a/test/protocol/url/relative.rb +++ b/test/protocol/url/relative.rb @@ -224,6 +224,14 @@ expect(url.relative_to("/index").to_s).to be == "./?q=ruby#examples" end + it "disambiguates a colon in the first segment" do + url = Protocol::URL::Relative.new("/docs/this:that") + relative_url = url.relative_to("/docs/index") + + expect(relative_url.to_s).to be == "./this:that" + expect(Protocol::URL[relative_url.to_s]).to be_a(Protocol::URL::Relative) + end + it "returns already-relative URLs unchanged" do url = Protocol::URL::Relative.new("../guide", "q=ruby", "examples")