Summary
Relative links in markdown content (e.g., ../sibling-page/) are resolved to incorrect absolute paths during server-side rendering. The link rewriter treats the page URL as a file path, but static output uses directory-style URLs (page/index.html), causing ../ to go up one level too many.
Reproduction
Given this file structure:
Content/reference/cli/commands/aspire.md
Content/reference/cli/commands/aspire-add.md
With aspire.md containing:
[aspire add](../aspire-add/)
Expected: The rendered HTML link resolves to /reference/cli/commands/aspire-add/ (sibling page in the same directory), matching how browsers resolve the relative URL from the directory-style output at /reference/cli/commands/aspire/index.html.
Actual: The rendered HTML contains href="/reference/cli/aspire-add/" — one directory too high, pointing to a non-existent page.
Root cause
In MarkdownContentService.cs (lines 118-119), the page URL is stripped to its parent directory before being passed to the link rewriter:
var lastSlash = page.Url.LastIndexOf('/');
var pageUrl = lastSlash == -1 ? page.Url : page.Url[..lastSlash];
For page URL /reference/cli/commands/aspire, this produces pageUrl = "/reference/cli/commands".
Then in LinkRewriter.GetAbsolutePathWithBaseUrl, ../aspire-add/ is resolved relative to /reference/cli/commands:
- Base segments:
["reference", "cli", "commands"]
- One
../ removes "commands" → ["reference", "cli"]
- Result:
/reference/cli/aspire-add/ ✗
But since the static output is commands/aspire/index.html, the browser sees the page at /reference/cli/commands/aspire/. From the browser's perspective:
- Base segments:
["reference", "cli", "commands", "aspire"]
- One
../ removes "aspire" → ["reference", "cli", "commands"]
- Result:
/reference/cli/commands/aspire-add/ ✓
Suggested fix
Pass the full page URL to the link rewriter instead of stripping the last segment:
// Before:
var lastSlash = page.Url.LastIndexOf('/');
var pageUrl = lastSlash == -1 ? page.Url : page.Url[..lastSlash];
var html = _markdownParserService.RenderMarkdownToHtml(page.MarkdownContent, pageUrl);
// After:
var html = _markdownParserService.RenderMarkdownToHtml(page.MarkdownContent, page.Url);
This makes the link rewriter treat the page as a directory (matching the page/index.html output convention), so ../ correctly resolves to the sibling level.
Note: LinkRewriter.GetAbsolutePathWithBaseUrl would also need to be updated — it currently assumes the relativeToUrl is already a directory. With the full page URL, the ../ counting logic would need to account for the page name being the last segment.
Summary
Relative links in markdown content (e.g.,
../sibling-page/) are resolved to incorrect absolute paths during server-side rendering. The link rewriter treats the page URL as a file path, but static output uses directory-style URLs (page/index.html), causing../to go up one level too many.Reproduction
Given this file structure:
With
aspire.mdcontaining:Expected: The rendered HTML link resolves to
/reference/cli/commands/aspire-add/(sibling page in the same directory), matching how browsers resolve the relative URL from the directory-style output at/reference/cli/commands/aspire/index.html.Actual: The rendered HTML contains
href="/reference/cli/aspire-add/"— one directory too high, pointing to a non-existent page.Root cause
In
MarkdownContentService.cs(lines 118-119), the page URL is stripped to its parent directory before being passed to the link rewriter:For page URL
/reference/cli/commands/aspire, this producespageUrl = "/reference/cli/commands".Then in
LinkRewriter.GetAbsolutePathWithBaseUrl,../aspire-add/is resolved relative to/reference/cli/commands:["reference", "cli", "commands"]../removes"commands"→["reference", "cli"]/reference/cli/aspire-add/✗But since the static output is
commands/aspire/index.html, the browser sees the page at/reference/cli/commands/aspire/. From the browser's perspective:["reference", "cli", "commands", "aspire"]../removes"aspire"→["reference", "cli", "commands"]/reference/cli/commands/aspire-add/✓Suggested fix
Pass the full page URL to the link rewriter instead of stripping the last segment:
This makes the link rewriter treat the page as a directory (matching the
page/index.htmloutput convention), so../correctly resolves to the sibling level.Note:
LinkRewriter.GetAbsolutePathWithBaseUrlwould also need to be updated — it currently assumes therelativeToUrlis already a directory. With the full page URL, the../counting logic would need to account for the page name being the last segment.