Skip to content

Commit 3b7d3f0

Browse files
authored
fix: anchor links broken when trailing slash is missing (#270)
## Summary - Fixes anchor links on all doc pages when accessed without a trailing slash (e.g. `/references/resource-limits` instead of `/references/resource-limits/`) - Root cause: `rehype-rewrite-links.mjs` was emitting relative hrefs (`../foo/#anchor`). Browser relative URL resolution depends on whether the current URL ends with a slash, causing the wrong target on non-trailing-slash URLs - Fix: the plugin now emits root-relative absolute hrefs (e.g. `/references/ic-interface-spec/canister-interface/#system-api-module`) by resolving each `.md` link against the current file's position in the docs tree — these are unaffected by the browser URL form - Also removes the now-unnecessary `isIndexPage` distinction (index and non-index pages are handled uniformly with absolute paths) - Affects all 118+ pages that use relative `.md` links Closes #269 ## Sync recommendation hand-written
1 parent 52f0c21 commit 3b7d3f0

1 file changed

Lines changed: 39 additions & 37 deletions

File tree

plugins/rehype-rewrite-links.mjs

Lines changed: 39 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,27 @@
11
/**
2-
* Rehype plugin that rewrites relative .md links for Astro's directory-based output.
2+
* Rehype plugin that rewrites relative .md links to absolute paths for Astro's directory-based output.
33
*
44
* Authors write GitHub-friendly relative links with .md extensions:
55
* [Quickstart](quickstart.md)
66
* [Concepts](../concepts/canisters.md#lifecycle)
77
*
8-
* Astro outputs each page as a directory (project-structure.md → project-structure/index.html),
9-
* so the browser resolves relative links one level deeper than the author expects.
10-
* This plugin strips .md extensions and prepends an extra ../ to compensate.
8+
* Astro outputs each page as a directory (resource-limits.md → resource-limits/index.html).
9+
* Relative hrefs in the output HTML are resolved by the browser relative to the current URL.
10+
* This means links break when the page is accessed without a trailing slash (e.g. /resource-limits
11+
* instead of /resource-limits/) because `../foo/` resolves to different paths in each case.
1112
*
12-
* Exception: index.md files are output as <dir>/index.html (not <dir>/<dir>/index.html),
13-
* so the browser's base URL is already at the correct directory level — no extra ../
14-
* is needed for those pages.
13+
* This plugin avoids the ambiguity by emitting absolute paths. It locates the current file
14+
* within the docs tree, resolves the relative .md link against that position, and writes a
15+
* root-relative href that works regardless of whether the browser URL has a trailing slash.
1516
*
16-
* Result (regular pages):
17-
* quickstart.md → ../quickstart/
18-
* ../concepts/canisters.md#lifecycle → ../../concepts/canisters/#lifecycle
19-
* ./sibling.md → ../sibling/
17+
* Result:
18+
* quickstart.md → /getting-started/quickstart/
19+
* ../concepts/canisters.md#lifecycle → /concepts/canisters/#lifecycle
20+
* ./sibling.md → /references/sibling/
21+
* backends/data-persistence.md → /guides/backends/data-persistence/
2022
*
21-
* Result (index pages):
22-
* backends/data-persistence.md → backends/data-persistence/
23-
* ../concepts/canisters.md → ../concepts/canisters/
24-
*
25-
* Only relative links are affected — external URLs, anchors, and absolute paths are untouched.
23+
* Only relative links with a .md extension are affected — external URLs, anchor-only links,
24+
* and already-absolute paths are untouched.
2625
*
2726
* Important: Astro caches rendered content in node_modules/.astro/data-store.json.
2827
* After changing this plugin, delete that file to force re-rendering.
@@ -31,15 +30,20 @@
3130
* Astro's markdown.remarkPlugins, but rehypePlugins are correctly merged. See:
3231
* https://github.com/dfinity/icp-cli/issues/423
3332
*/
33+
import { posix as posixPath } from "path";
3434
import { visit } from "unist-util-visit";
3535

3636
export default function rehypeRewriteLinks() {
3737
return (tree, file) => {
38-
// Detect whether this file is an index page (e.g. guides/index.md).
39-
// Index pages are output as <dir>/index.html, so the browser's base URL
40-
// is already at the directory level — no extra ../ compensation needed.
41-
const filePath = file?.path || file?.history?.[0] || "";
42-
const isIndexPage = /(?:^|[\\/])index\.(?:md|mdx)$/.test(filePath);
38+
const filePath = (file?.path || file?.history?.[0] || "").replace(/\\/g, "/");
39+
40+
// Extract the docs-relative directory of the current file.
41+
// Handles both the real path (.../docs/references/resource-limits.md)
42+
// and the symlinked path (.../src/content/docs/references/resource-limits.md).
43+
const docsRelMatch = filePath.match(/(?:\/src\/content\/docs|\/docs)\/(.*)/);
44+
const docsRelPath = docsRelMatch ? docsRelMatch[1] : "";
45+
// e.g. "references/resource-limits.md" → "references/"
46+
const fileDir = docsRelPath.replace(/[^/]+$/, "");
4347

4448
visit(tree, "element", (node) => {
4549
if (node.tagName !== "a") return;
@@ -68,30 +72,28 @@ export default function rehypeRewriteLinks() {
6872
url = url.replace(/(^|\/)index(#|$|\?)/, "$1$2");
6973

7074
// Split off anchor/query suffix
71-
const splitMatch = url.match(/^([^#?]*)((?:#|\\?).*)?$/);
72-
let path = splitMatch[1] || "";
75+
const splitMatch = url.match(/^([^#?]*)((?:#|\?).*)?$/);
76+
let linkPath = splitMatch[1] || "";
7377
const suffix = splitMatch[2] || "";
7478

7579
// Add trailing slash if the path doesn't already end with one
76-
if (path && !path.endsWith("/")) {
77-
path += "/";
80+
if (linkPath && !linkPath.endsWith("/")) {
81+
linkPath += "/";
7882
}
7983

80-
// Strip leading ./ if present (normalize before prepending ../)
81-
if (path.startsWith("./")) {
82-
path = path.slice(2);
84+
// Strip leading ./ if present
85+
if (linkPath.startsWith("./")) {
86+
linkPath = linkPath.slice(2);
8387
}
8488

85-
// Prepend ../ to compensate for Astro's directory-based output.
86-
// Regular pages (e.g. project-structure.md → project-structure/index.html)
87-
// need the extra ../ because the browser base is one level deeper than
88-
// the author expects. Index pages don't need this — they're already at
89-
// the correct directory level.
90-
if (!isIndexPage) {
91-
path = "../" + path;
92-
}
89+
// Resolve the relative link against the current file's absolute docs path.
90+
// posixPath.resolve strips trailing slashes, so re-add one afterward.
91+
// This produces a root-relative href that works regardless of whether the
92+
// browser URL has a trailing slash.
93+
const resolved = posixPath.resolve("/" + fileDir, linkPath || ".");
94+
const absoluteHref = resolved === "/" ? "/" : resolved + "/";
9395

94-
node.properties.href = path + suffix;
96+
node.properties.href = absoluteHref + suffix;
9597
});
9698
};
9799
}

0 commit comments

Comments
 (0)