BMAD-METHOD/website/src/rehype-base-paths.js

113 lines
3.9 KiB
JavaScript

/**
* Rehype plugin to prepend base path to absolute URLs
*
* Transforms:
* /img/foo.png → /BMAD-METHOD/img/foo.png (when base is /BMAD-METHOD/)
* /llms.txt → /BMAD-METHOD/llms.txt
*
* Supported elements:
* - img[src], iframe[src], video[src], source[src], audio[src]
* - a[href], link[href]
*
* Only affects absolute paths (/) - relative paths and external URLs are unchanged.
* Does NOT process .md links (those are handled by rehype-markdown-links).
*/
import { visit } from 'unist-util-visit';
/**
* Create a rehype plugin that prepends the base path to absolute URLs.
*
* @param {Object} options - Plugin options
* @param {string} options.base - The base path to prepend (e.g., '/BMAD-METHOD/')
* @returns {function} A HAST tree transformer
*/
export default function rehypeBasePaths(options = {}) {
const base = options.base || '/';
// Normalize base: ensure trailing slash so concatenation with path.slice(1) (no leading /)
// produces correct paths like /BMAD-METHOD/img/foo.png.
// Note: rehype-markdown-links uses the opposite convention (strips trailing slash) because
// it concatenates with paths that start with /.
const normalizedBase = base === '/' ? '/' : base.endsWith('/') ? base : base + '/';
/**
* Prepend base path to an absolute URL attribute if needed.
* Skips protocol-relative URLs (//) and paths that already include the base.
*
* @param {object} node - HAST element node
* @param {string} attr - Attribute name ('src' or 'href')
*/
function prependBase(node, attr) {
const value = node.properties?.[attr];
if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//')) {
return;
}
if (normalizedBase !== '/' && !value.startsWith(normalizedBase)) {
node.properties[attr] = normalizedBase + value.slice(1);
}
}
return (tree) => {
// Handle raw HTML blocks (inline HTML in markdown that isn't parsed into HAST elements)
if (normalizedBase !== '/') {
visit(tree, 'raw', (node) => {
// Replace absolute src="/..." and href="/..." attributes, skipping protocol-relative
// and paths that already have the base prefix
node.value = node.value.replace(/(?<attr>\b(?:src|href))="(?<path>\/(?!\/)[^"]*)"/g, (match, attr, pathValue) => {
if (pathValue.startsWith(normalizedBase)) return match;
return `${attr}="${normalizedBase}${pathValue.slice(1)}"`;
});
});
}
visit(tree, 'element', (node) => {
const tag = node.tagName;
// Tags with src attribute
if (['img', 'iframe', 'video', 'source', 'audio'].includes(tag)) {
prependBase(node, 'src');
}
// Link tags with href attribute (stylesheets, preloads, etc.)
if (tag === 'link') {
prependBase(node, 'href');
}
// Anchor tags need special handling - skip .md links
if (tag === 'a' && node.properties?.href) {
const href = node.properties.href;
if (typeof href !== 'string') {
return;
}
// Only transform absolute paths starting with / (but not //)
if (!href.startsWith('/') || href.startsWith('//')) {
return;
}
// Skip if already has the base path
if (normalizedBase !== '/' && href.startsWith(normalizedBase)) {
return;
}
// Skip .md links - those are handled by rehype-markdown-links
// Extract path portion (before ? and #)
const firstDelimiter = Math.min(
href.indexOf('?') === -1 ? Infinity : href.indexOf('?'),
href.indexOf('#') === -1 ? Infinity : href.indexOf('#'),
);
const pathPortion = firstDelimiter === Infinity ? href : href.substring(0, firstDelimiter);
if (pathPortion.endsWith('.md')) {
return; // Let rehype-markdown-links handle this
}
// Prepend base path
node.properties.href = normalizedBase + href.slice(1);
}
});
};
}