120 lines
4.7 KiB
JavaScript
120 lines
4.7 KiB
JavaScript
/**
|
|
* Rehype plugin to transform relative .md links into correct site URLs.
|
|
*
|
|
* Uses the source file's disk path (via vfile) to resolve the link target,
|
|
* then computes the output URL relative to the content root directory.
|
|
* This correctly handles Starlight's directory-per-page URL structure
|
|
* where ./sibling.md from reference/testing.md must become /reference/sibling/
|
|
* (not ./sibling/ which would resolve to /reference/testing/sibling/).
|
|
*
|
|
* Supports: ./sibling.md, ../other/page.md, bare.md, /docs/absolute.md
|
|
* Preserves: query strings, hash anchors
|
|
* Skips: external URLs, non-.md links
|
|
*/
|
|
|
|
import { visit } from 'unist-util-visit';
|
|
import path from 'node:path';
|
|
|
|
/**
|
|
* @param {Object} options
|
|
* @param {string} options.base - Site base path (e.g., '/BMAD-METHOD/')
|
|
* @param {string} [options.contentDir] - Absolute path to content root; auto-detected if omitted
|
|
*/
|
|
export default function rehypeMarkdownLinks(options = {}) {
|
|
const base = options.base || '/';
|
|
const normalizedBase = base === '/' ? '' : base.replace(/\/$/, '');
|
|
|
|
return (tree, file) => {
|
|
// The current file's absolute path on disk, set by Astro's markdown pipeline
|
|
const currentFilePath = file.path;
|
|
if (!currentFilePath) return;
|
|
|
|
// Auto-detect content root: walk up from current file to find src/content/docs
|
|
const contentDir = options.contentDir || detectContentDir(currentFilePath);
|
|
if (!contentDir) {
|
|
throw new Error(`[rehype-markdown-links] Could not detect content directory for: ${currentFilePath}`);
|
|
}
|
|
|
|
visit(tree, 'element', (node) => {
|
|
if (node.tagName !== 'a' || typeof node.properties?.href !== 'string') {
|
|
return;
|
|
}
|
|
|
|
const href = node.properties.href;
|
|
|
|
// Skip external links (including protocol-relative URLs like //cdn.example.com)
|
|
if (href.includes('://') || href.startsWith('//') || href.startsWith('mailto:') || href.startsWith('tel:')) {
|
|
return;
|
|
}
|
|
|
|
// Split href into path vs query+fragment suffix
|
|
const delimIdx = findFirstDelimiter(href);
|
|
const linkPath = delimIdx === -1 ? href : href.substring(0, delimIdx);
|
|
const suffix = delimIdx === -1 ? '' : href.substring(delimIdx);
|
|
|
|
// Only process .md links
|
|
if (!linkPath.endsWith('.md')) return;
|
|
|
|
// Resolve the target file's absolute path on disk
|
|
let targetPath;
|
|
if (linkPath.startsWith('/docs/')) {
|
|
// Absolute /docs/ path — resolve from content root
|
|
targetPath = path.join(contentDir, linkPath.slice(5)); // strip '/docs'
|
|
} else if (linkPath.startsWith('/')) {
|
|
// Other absolute paths — resolve from content root
|
|
targetPath = path.join(contentDir, linkPath);
|
|
} else {
|
|
// Relative path (./sibling.md, ../other.md, bare.md) — resolve from current file
|
|
targetPath = path.resolve(path.dirname(currentFilePath), linkPath);
|
|
}
|
|
|
|
// Compute the target's path relative to content root
|
|
const relativeToContent = path.relative(contentDir, targetPath);
|
|
|
|
// Safety: skip if target resolves outside content root
|
|
if (relativeToContent.startsWith('..')) return;
|
|
|
|
// Convert file path to URL: strip .md, handle index, ensure leading/trailing slashes
|
|
let urlPath = relativeToContent.replace(/\.md$/, '');
|
|
|
|
// index.md becomes the directory root
|
|
if (urlPath.endsWith('/index') || urlPath === 'index') {
|
|
urlPath = urlPath.slice(0, -'index'.length);
|
|
}
|
|
|
|
// Build absolute URL with base path, normalizing any double slashes
|
|
const raw = normalizedBase + '/' + urlPath.replace(/\/?$/, '/') + suffix;
|
|
node.properties.href = raw.replace(/\/\/+/g, '/');
|
|
});
|
|
};
|
|
}
|
|
|
|
/** Find the index of the first ? or # in a string, or -1 if neither exists. */
|
|
export function findFirstDelimiter(str) {
|
|
const q = str.indexOf('?');
|
|
const h = str.indexOf('#');
|
|
if (q === -1) return h;
|
|
if (h === -1) return q;
|
|
return Math.min(q, h);
|
|
}
|
|
|
|
/** Walk up from a file path to find the content docs directory. */
|
|
export function detectContentDir(filePath) {
|
|
const segments = filePath.split(path.sep);
|
|
// Look for src/content/docs in the path (standard Astro)
|
|
for (let i = segments.length - 1; i >= 2; i--) {
|
|
if (segments[i - 2] === 'src' && segments[i - 1] === 'content' && segments[i] === 'docs') {
|
|
return segments.slice(0, i + 1).join(path.sep);
|
|
}
|
|
}
|
|
// Also check for a standalone 'docs' directory (BMAD project structure)
|
|
// Path format: .../bmm/docs/file.mdx or .../bmm/website/...
|
|
for (let i = segments.length - 1; i >= 0; i--) {
|
|
if (segments[i] === 'docs') {
|
|
// Found docs directory - use its parent as the content root
|
|
return segments.slice(0, i + 1).join(path.sep);
|
|
}
|
|
}
|
|
return null;
|
|
}
|