Skip to content

Commit 2c5300f

Browse files
CopilotGrotax
authored andcommitted
fix: resolve ../ and ./ relative links in feed item content
Add resolveRelativePathLinksInContent() private method to Node that handles href/src attributes starting with ../ or ./. These paths are normalized against the item's base directory using stack-based path resolution, then replaced with absolute URLs. Previously, Node::setHostInContent() only handled: - /absolute/path hrefs - #fragment hrefs - word-starting relative hrefs (e.g. page.html) Relative paths with dot segments (../path, ./path) were left unresolved, breaking links in feeds like https://wiki.xxiivv.com/links/log.xml whose <description> elements contain HTML with ../media/ and ../site/ hrefs. Also add tests and a sample RSS fixture with relative ../ links.
1 parent c58ff41 commit 2c5300f

4 files changed

Lines changed: 215 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Changed
1111

1212
### Fixed
13+
- fix: resolve `../` and `./` relative links in feed item HTML content (#95)
1314

1415
## Released
1516
## [v6.4.0] - 2026-06-25

‎src/FeedIo/Feed/Node.php‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,11 @@ public function setHostInContent(?string $host = null): NodeInterface
183183
$this->pregReplaceInProperty('content', $pattern, '\1\2\3'.$itemFullLink.'\4');
184184
$this->pregReplaceInProperty('description', $pattern, '\1\2\3'.$itemFullLink.'\4');
185185

186+
// Replaced links like href="../aaa/bbb.xxx" or href="./aaa/bbb.xxx"
187+
if ($itemFullLink !== null) {
188+
$this->resolveRelativePathLinksInContent($itemFullLink);
189+
}
190+
186191
// Replaced links like href="aaa/bbb.xxx"
187192
$pattern = '(<\s*[^>]*)(href=|src=)(.?)(\w+\b)(?![:])(?!(.(?!<code))*<\/code>)';
188193
$this->pregReplaceInProperty('content', $pattern, '\1\2\3'.$itemLink.'\4');
@@ -191,6 +196,90 @@ public function setHostInContent(?string $host = null): NodeInterface
191196
return $this;
192197
}
193198

199+
private function resolveRelativePathLinksInContent(string $itemFullLink): void
200+
{
201+
$parsed = parse_url($itemFullLink);
202+
if (!is_array($parsed) || !isset($parsed['scheme'], $parsed['host'])) {
203+
return;
204+
}
205+
206+
$scheme = $parsed['scheme'];
207+
$host = $parsed['host'];
208+
if (str_contains($host, ':') && !str_starts_with($host, '[')) {
209+
$host = '[' . $host . ']';
210+
}
211+
$authority = $host;
212+
if (isset($parsed['port'])) {
213+
$authority .= ':' . $parsed['port'];
214+
}
215+
if (isset($parsed['user'])) {
216+
$userinfo = $parsed['user'];
217+
if (isset($parsed['pass'])) {
218+
$userinfo .= ':' . $parsed['pass'];
219+
}
220+
$authority = $userinfo . '@' . $authority;
221+
}
222+
$basePath = $parsed['path'] ?? '/';
223+
$baseDir = substr($basePath, 0, strrpos($basePath, '/') + 1) ?: '/';
224+
225+
$resolver = function (array $matches) use ($scheme, $authority, $baseDir): string {
226+
$href = $matches[3];
227+
$merged = $baseDir . $href;
228+
$segments = [];
229+
foreach (explode('/', $merged) as $segment) {
230+
if ($segment === '..') {
231+
if (!empty($segments)) {
232+
array_pop($segments);
233+
}
234+
} elseif ($segment !== '.') {
235+
$segments[] = $segment;
236+
}
237+
}
238+
$path = implode('/', $segments) ?: '/';
239+
if (!str_starts_with($path, '/')) {
240+
$path = '/' . $path;
241+
}
242+
243+
return $matches[1] . $matches[2] . $scheme . '://' . $authority . $path . $matches[2];
244+
};
245+
246+
foreach (['content', 'description'] as $property) {
247+
$this->replaceRelativeLinksInProperty($property, $resolver);
248+
}
249+
}
250+
251+
private function replaceRelativeLinksInProperty(string $property, callable $resolver): void
252+
{
253+
if (!property_exists($this, $property) || is_null($this->{$property})) {
254+
return;
255+
}
256+
257+
$pattern = '~((?:href|src)=)(["\'])(\.{1,2}/[^"\'<>\s]*)\2~i';
258+
$segments = preg_split('/(<\/?code>)/i', $this->{$property}, -1, PREG_SPLIT_DELIM_CAPTURE);
259+
if ($segments === false) {
260+
return;
261+
}
262+
263+
$result = '';
264+
$insideCode = false;
265+
foreach ($segments as $segment) {
266+
if (preg_match('/^<\/?code>$/i', $segment)) {
267+
$insideCode = !$insideCode;
268+
$result .= $segment;
269+
continue;
270+
}
271+
272+
if ($insideCode) {
273+
$result .= $segment;
274+
continue;
275+
}
276+
277+
$result .= preg_replace_callback($pattern, $resolver, $segment) ?? $segment;
278+
}
279+
280+
$this->{$property} = $result;
281+
}
282+
194283
public function pregReplaceInProperty(string $property, string $pattern, string $replacement): void
195284
{
196285
if (property_exists($this, $property) && !is_null($this->{$property})) {

‎tests/FeedIo/Feed/NodeTest.php‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,109 @@ public function testGetHostFromLinkReturnsNullForRelativePath()
112112

113113
$this->assertNull($this->object->getHostFromLink());
114114
}
115+
116+
public function testSetHostInContentResolvesDotDotLinks(): void
117+
{
118+
$item = new Item();
119+
$item->setLink('https://wiki.xxiivv.com/site/2026.html#20N');
120+
$item->setContent("<p><a href='../site/metadata.html'>Metadata</a></p><img src='../media/refs/hello.png'/>");
121+
122+
$item->setHostInContent('https://wiki.xxiivv.com');
123+
124+
$this->assertStringContainsString(
125+
"href='https://wiki.xxiivv.com/site/metadata.html'",
126+
$item->getContent()
127+
);
128+
$this->assertStringContainsString(
129+
"src='https://wiki.xxiivv.com/media/refs/hello.png'",
130+
$item->getContent()
131+
);
132+
}
133+
134+
public function testSetHostInContentResolvesDotLinks(): void
135+
{
136+
$item = new Item();
137+
$item->setLink('https://example.com/dir/page.html');
138+
$item->setContent('<a href="./sibling.html">Sibling</a>');
139+
140+
$item->setHostInContent('https://example.com');
141+
142+
$this->assertStringContainsString(
143+
'href="https://example.com/dir/sibling.html"',
144+
$item->getContent()
145+
);
146+
}
147+
148+
public function testSetHostInContentPreservesQueryStringsAndFragments(): void
149+
{
150+
$item = new Item();
151+
$item->setLink('https://example.com/dir/page.html');
152+
$item->setContent('<a href="../page.html?x=1#sec">Sibling</a>');
153+
154+
$item->setHostInContent('https://example.com');
155+
156+
$this->assertStringContainsString(
157+
'href="https://example.com/page.html?x=1#sec"',
158+
$item->getContent()
159+
);
160+
}
161+
162+
public function testSetHostInContentKeepsAuthorityComponentsForRelativeLinks(): void
163+
{
164+
$cases = [
165+
[
166+
'link' => 'https://user:pass@example.com:8080/dir/page.html',
167+
'expected' => 'https://user:pass@example.com:8080/page.html',
168+
],
169+
[
170+
'link' => 'https://example.com:8080/dir/page.html',
171+
'expected' => 'https://example.com:8080/page.html',
172+
],
173+
[
174+
'link' => 'https://[2001:db8::1]/dir/page.html',
175+
'expected' => 'https://[2001:db8::1]/page.html',
176+
],
177+
];
178+
179+
foreach ($cases as $case) {
180+
$item = new Item();
181+
$item->setLink($case['link']);
182+
$item->setContent('<a href="../page.html">Sibling</a>');
183+
184+
$item->setHostInContent('https://example.com');
185+
186+
$this->assertStringContainsString(
187+
'href="' . $case['expected'] . '"',
188+
$item->getContent()
189+
);
190+
}
191+
}
192+
193+
public function testSetHostInContentDoesNotModifyLinksInsideCodeTags(): void
194+
{
195+
$item = new Item();
196+
$item->setLink('https://example.com/dir/page.html');
197+
$item->setContent('<code><a href="../page.html">Sibling</a></code>');
198+
199+
$item->setHostInContent('https://example.com');
200+
201+
$this->assertStringContainsString(
202+
'<code><a href="../page.html">Sibling</a></code>',
203+
$item->getContent()
204+
);
205+
}
206+
207+
public function testSetHostInContentDoesNotModifyAbsoluteLinksInContent(): void
208+
{
209+
$item = new Item();
210+
$item->setLink('https://example.com/dir/page.html');
211+
$item->setContent('<a href="https://other.example.com/page.html">Other</a>');
212+
213+
$item->setHostInContent('https://example.com');
214+
215+
$this->assertStringContainsString(
216+
'href="https://other.example.com/page.html"',
217+
$item->getContent()
218+
);
219+
}
115220
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<rss version="2.0">
3+
<channel>
4+
<title>XXIIVV</title>
5+
<link>https://wiki.xxiivv.com/</link>
6+
<description>Journal Logs</description>
7+
<item>
8+
<link>https://wiki.xxiivv.com/site/2026.html#20N</link>
9+
<pubDate>Thu, 02 Jul 2026 12:00:00 +0000</pubDate>
10+
<title>Ulysses</title>
11+
<description><![CDATA[ <p>Excerpt.</p> <ul> <li><a href='../site/metadata.html'>Metadata</a></li> </ul> <img src='../media/refs/hello.png' width='140'/> ]]></description>
12+
</item>
13+
<item>
14+
<link>https://wiki.xxiivv.com/site/2026.html#20M</link>
15+
<pubDate>Thu, 18 Jun 2026 12:00:00 +0000</pubDate>
16+
<title>They live deeply</title>
17+
<description><![CDATA[ <p>Another entry.</p> <ul> <li><a href='../site/flick.html'>Flick</a></li> </ul> <img src='../media/refs/geometryfun.png' width='140'/> ]]></description>
18+
</item>
19+
</channel>
20+
</rss>

0 commit comments

Comments
 (0)