/** * 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; }