rehype-smart-links
Version:
A rehype plugin for Astro that adds different styling for internal and external links
549 lines (541 loc) • 20.3 kB
JavaScript
// src/index.ts
import { existsSync, readdirSync, readFileSync, writeFileSync } from "fs";
import { extname, join } from "path";
// node_modules/.pnpm/hast-util-is-element@3.0.0/node_modules/hast-util-is-element/lib/index.js
var isElement = (
// Note: overloads in JSDoc can’t yet use different `@template`s.
/**
* @type {(
* (<Condition extends TestFunction>(element: unknown, test: Condition, index?: number | null | undefined, parent?: Parents | null | undefined, context?: unknown) => element is Element & Predicate<Condition, Element>) &
* (<Condition extends string>(element: unknown, test: Condition, index?: number | null | undefined, parent?: Parents | null | undefined, context?: unknown) => element is Element & {tagName: Condition}) &
* ((element?: null | undefined) => false) &
* ((element: unknown, test?: null | undefined, index?: number | null | undefined, parent?: Parents | null | undefined, context?: unknown) => element is Element) &
* ((element: unknown, test?: Test, index?: number | null | undefined, parent?: Parents | null | undefined, context?: unknown) => boolean)
* )}
*/
/**
* @param {unknown} [element]
* @param {Test | undefined} [test]
* @param {number | null | undefined} [index]
* @param {Parents | null | undefined} [parent]
* @param {unknown} [context]
* @returns {boolean}
*/
// eslint-disable-next-line max-params
function(element2, test, index, parent, context) {
const check = convertElement(test);
if (index !== null && index !== void 0 && (typeof index !== "number" || index < 0 || index === Number.POSITIVE_INFINITY)) {
throw new Error("Expected positive finite `index`");
}
if (parent !== null && parent !== void 0 && (!parent.type || !parent.children)) {
throw new Error("Expected valid `parent`");
}
if ((index === null || index === void 0) !== (parent === null || parent === void 0)) {
throw new Error("Expected both `index` and `parent`");
}
return looksLikeAnElement(element2) ? check.call(context, element2, index, parent) : false;
}
);
var convertElement = (
// Note: overloads in JSDoc can’t yet use different `@template`s.
/**
* @type {(
* (<Condition extends TestFunction>(test: Condition) => (element: unknown, index?: number | null | undefined, parent?: Parents | null | undefined, context?: unknown) => element is Element & Predicate<Condition, Element>) &
* (<Condition extends string>(test: Condition) => (element: unknown, index?: number | null | undefined, parent?: Parents | null | undefined, context?: unknown) => element is Element & {tagName: Condition}) &
* ((test?: null | undefined) => (element?: unknown, index?: number | null | undefined, parent?: Parents | null | undefined, context?: unknown) => element is Element) &
* ((test?: Test) => Check)
* )}
*/
/**
* @param {Test | null | undefined} [test]
* @returns {Check}
*/
function(test) {
if (test === null || test === void 0) {
return element;
}
if (typeof test === "string") {
return tagNameFactory(test);
}
if (typeof test === "object") {
return anyFactory(test);
}
if (typeof test === "function") {
return castFactory(test);
}
throw new Error("Expected function, string, or array as `test`");
}
);
function anyFactory(tests) {
const checks = [];
let index = -1;
while (++index < tests.length) {
checks[index] = convertElement(tests[index]);
}
return castFactory(any);
function any(...parameters) {
let index2 = -1;
while (++index2 < checks.length) {
if (checks[index2].apply(this, parameters))
return true;
}
return false;
}
}
function tagNameFactory(check) {
return castFactory(tagName);
function tagName(element2) {
return element2.tagName === check;
}
}
function castFactory(testFunction) {
return check;
function check(value, index, parent) {
return Boolean(
looksLikeAnElement(value) && testFunction.call(
this,
value,
typeof index === "number" ? index : void 0,
parent || void 0
)
);
}
}
function element(element2) {
return Boolean(
element2 && typeof element2 === "object" && "type" in element2 && element2.type === "element" && "tagName" in element2 && typeof element2.tagName === "string"
);
}
function looksLikeAnElement(value) {
return value !== null && typeof value === "object" && "type" in value && "tagName" in value;
}
// node_modules/.pnpm/unist-util-is@6.0.0/node_modules/unist-util-is/lib/index.js
var convert = (
// Note: overloads in JSDoc can’t yet use different `@template`s.
/**
* @type {(
* (<Condition extends string>(test: Condition) => (node: unknown, index?: number | null | undefined, parent?: Parent | null | undefined, context?: unknown) => node is Node & {type: Condition}) &
* (<Condition extends Props>(test: Condition) => (node: unknown, index?: number | null | undefined, parent?: Parent | null | undefined, context?: unknown) => node is Node & Condition) &
* (<Condition extends TestFunction>(test: Condition) => (node: unknown, index?: number | null | undefined, parent?: Parent | null | undefined, context?: unknown) => node is Node & Predicate<Condition, Node>) &
* ((test?: null | undefined) => (node?: unknown, index?: number | null | undefined, parent?: Parent | null | undefined, context?: unknown) => node is Node) &
* ((test?: Test) => Check)
* )}
*/
/**
* @param {Test} [test]
* @returns {Check}
*/
function(test) {
if (test === null || test === void 0) {
return ok;
}
if (typeof test === "function") {
return castFactory2(test);
}
if (typeof test === "object") {
return Array.isArray(test) ? anyFactory2(test) : propsFactory(test);
}
if (typeof test === "string") {
return typeFactory(test);
}
throw new Error("Expected function, string, or object as test");
}
);
function anyFactory2(tests) {
const checks = [];
let index = -1;
while (++index < tests.length) {
checks[index] = convert(tests[index]);
}
return castFactory2(any);
function any(...parameters) {
let index2 = -1;
while (++index2 < checks.length) {
if (checks[index2].apply(this, parameters))
return true;
}
return false;
}
}
function propsFactory(check) {
const checkAsRecord = (
/** @type {Record<string, unknown>} */
check
);
return castFactory2(all);
function all(node) {
const nodeAsRecord = (
/** @type {Record<string, unknown>} */
/** @type {unknown} */
node
);
let key;
for (key in check) {
if (nodeAsRecord[key] !== checkAsRecord[key])
return false;
}
return true;
}
}
function typeFactory(check) {
return castFactory2(type);
function type(node) {
return node && node.type === check;
}
}
function castFactory2(testFunction) {
return check;
function check(value, index, parent) {
return Boolean(
looksLikeANode(value) && testFunction.call(
this,
value,
typeof index === "number" ? index : void 0,
parent || void 0
)
);
}
}
function ok() {
return true;
}
function looksLikeANode(value) {
return value !== null && typeof value === "object" && "type" in value;
}
// node_modules/.pnpm/unist-util-visit-parents@6.0.1/node_modules/unist-util-visit-parents/lib/color.node.js
function color(d) {
return "\x1B[33m" + d + "\x1B[39m";
}
// node_modules/.pnpm/unist-util-visit-parents@6.0.1/node_modules/unist-util-visit-parents/lib/index.js
var empty = [];
var CONTINUE = true;
var EXIT = false;
var SKIP = "skip";
function visitParents(tree, test, visitor, reverse) {
let check;
if (typeof test === "function" && typeof visitor !== "function") {
reverse = visitor;
visitor = test;
} else {
check = test;
}
const is2 = convert(check);
const step = reverse ? -1 : 1;
factory(tree, void 0, [])();
function factory(node, index, parents) {
const value = (
/** @type {Record<string, unknown>} */
node && typeof node === "object" ? node : {}
);
if (typeof value.type === "string") {
const name = (
// `hast`
typeof value.tagName === "string" ? value.tagName : (
// `xast`
typeof value.name === "string" ? value.name : void 0
)
);
Object.defineProperty(visit2, "name", {
value: "node (" + color(node.type + (name ? "<" + name + ">" : "")) + ")"
});
}
return visit2;
function visit2() {
let result = empty;
let subresult;
let offset;
let grandparents;
if (!test || is2(node, index, parents[parents.length - 1] || void 0)) {
result = toResult(visitor(node, parents));
if (result[0] === EXIT) {
return result;
}
}
if ("children" in node && node.children) {
const nodeAsParent = (
/** @type {UnistParent} */
node
);
if (nodeAsParent.children && result[0] !== SKIP) {
offset = (reverse ? nodeAsParent.children.length : -1) + step;
grandparents = parents.concat(nodeAsParent);
while (offset > -1 && offset < nodeAsParent.children.length) {
const child = nodeAsParent.children[offset];
subresult = factory(child, offset, grandparents)();
if (subresult[0] === EXIT) {
return subresult;
}
offset = typeof subresult[1] === "number" ? subresult[1] : offset + step;
}
}
}
return result;
}
}
}
function toResult(value) {
if (Array.isArray(value)) {
return value;
}
if (typeof value === "number") {
return [CONTINUE, value];
}
return value === null || value === void 0 ? empty : [value];
}
// node_modules/.pnpm/unist-util-visit@5.0.0/node_modules/unist-util-visit/lib/index.js
function visit(tree, testOrVisitor, visitorOrReverse, maybeReverse) {
let reverse;
let test;
let visitor;
if (typeof testOrVisitor === "function" && typeof visitorOrReverse !== "function") {
test = void 0;
visitor = testOrVisitor;
reverse = visitorOrReverse;
} else {
test = testOrVisitor;
visitor = visitorOrReverse;
reverse = maybeReverse;
}
visitParents(tree, test, overload, reverse);
function overload(node, parents) {
const parent = parents[parents.length - 1];
const index = parent ? parent.children.indexOf(node) : void 0;
return visitor(node, index, parent);
}
}
// src/index.ts
var colors = {
reset: "\x1B[0m",
red: "\x1B[31m",
green: "\x1B[32m",
yellow: "\x1B[33m",
blue: "\x1B[34m",
magenta: "\x1B[35m",
cyan: "\x1B[36m",
dim: "\x1B[2m",
bold: "\x1B[1m"
};
var defaultOptions = {
content: { type: "text", value: "\u2197" },
internalLinkClass: "internal-link",
externalLinkClass: "external-link",
brokenLinkClass: "broken-link",
contentClass: "external-icon",
target: "_blank",
rel: "noopener noreferrer",
publicDir: "./dist",
routesFile: "./.smart-links-routes.json",
includeFileExtensions: ["html"],
includeAllFiles: false
};
function addClass(node, className) {
if (!className)
return;
const currentClasses = node.properties?.className;
if (!currentClasses) {
node.properties.className = [className];
} else if (Array.isArray(currentClasses)) {
node.properties.className = [...currentClasses, className];
} else if (typeof currentClasses === "string") {
node.properties.className = [currentClasses, className];
} else {
node.properties.className = [className];
}
}
function normalizePath(path) {
path = path.replace(/^\/|\/$/g, "");
if (path === "")
return "index";
if (path.endsWith("/index")) {
path = path.slice(0, -6);
}
return path;
}
function scanDirectoryForRoutes(dir, basePath = "", routes = /* @__PURE__ */ new Set(), options = defaultOptions) {
if (!existsSync(dir)) {
console.warn(`${colors.yellow}[WARNING]${colors.reset} Directory not found: ${colors.bold}${dir}${colors.reset}`);
return routes;
}
const entries = readdirSync(dir, { withFileTypes: true });
console.warn(`${colors.dim}[SCAN]${colors.reset} Scanning directory: ${colors.bold}${dir}${colors.reset} (${entries.length} entries)`);
for (const entry of entries) {
const fullPath = join(dir, entry.name);
const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
const dirRoute = normalizePath(relativePath);
routes.add(dirRoute);
console.warn(`${colors.blue}[DIR]${colors.reset} Added directory route: ${colors.bold}${dirRoute}${colors.reset}`);
scanDirectoryForRoutes(fullPath, relativePath, routes, options);
} else if (entry.isFile()) {
const extension = extname(entry.name).slice(1).toLowerCase();
const shouldInclude = options.includeAllFiles || !options.includeFileExtensions || options.includeFileExtensions.includes(extension);
if (shouldInclude) {
let route = relativePath;
if (extension === "html" && options.includeFileExtensions?.includes("html")) {
route = relativePath.replace(/\.html$/, "");
if (entry.name === "index.html") {
route = basePath;
}
}
const normalizedRoute = normalizePath(route);
routes.add(normalizedRoute);
console.warn(`${colors.green}[FILE]${colors.reset} Added route: ${colors.bold}${normalizedRoute}${colors.reset} (${extension})`);
} else {
console.warn(`${colors.dim}[SKIP]${colors.reset} Skipped file: ${colors.dim}${relativePath}${colors.reset} (${extension})`);
}
}
}
return routes;
}
function generateRoutesFile(buildDir = "./dist", routesFilePath = "./.smart-links-routes.json", options = {}) {
console.warn(`${colors.cyan}[INFO]${colors.reset} ${colors.bold}Smart Links Route Generation${colors.reset}`);
console.warn(`${colors.cyan}[INFO]${colors.reset} Scanning build directory: ${colors.bold}${buildDir}${colors.reset}`);
const scanOptions = {
...defaultOptions,
...options
};
console.warn(`${colors.cyan}[CONFIG]${colors.reset} Include all files: ${colors.bold}${scanOptions.includeAllFiles ? "Yes" : "No"}${colors.reset}`);
if (!scanOptions.includeAllFiles && scanOptions.includeFileExtensions) {
console.warn(`${colors.cyan}[CONFIG]${colors.reset} Included extensions: ${colors.bold}${scanOptions.includeFileExtensions.join(", ")}${colors.reset}`);
}
const routes = scanDirectoryForRoutes(buildDir, "", /* @__PURE__ */ new Set(), scanOptions);
routes.add("index");
routes.add("");
console.warn(`${colors.blue}[ROUTE]${colors.reset} Added default route: ${colors.bold}/${colors.reset}`);
console.warn(`${colors.cyan}[SUMMARY]${colors.reset} Found ${colors.bold}${routes.size}${colors.reset} routes in the build directory`);
try {
writeFileSync(routesFilePath, JSON.stringify(Array.from(routes), null, 2));
console.warn(`${colors.green}[SUCCESS]${colors.reset} Routes file generated: ${colors.bold}${routesFilePath}${colors.reset}`);
} catch (error) {
console.error(`${colors.red}[ERROR]${colors.reset} Failed to write routes file: ${colors.bold}${error instanceof Error ? error.message : String(error)}${colors.reset}`);
}
}
function loadRoutesFromFile(routesFilePath) {
const routes = /* @__PURE__ */ new Set();
routes.add("index");
routes.add("");
if (!existsSync(routesFilePath)) {
console.warn(`${colors.yellow}[WARNING]${colors.reset} Routes file not found: ${colors.bold}${routesFilePath}${colors.reset}`);
return routes;
}
try {
const routesData = readFileSync(routesFilePath, "utf-8");
const parsedRoutes = JSON.parse(routesData);
for (const route of parsedRoutes) {
routes.add(normalizePath(route));
}
} catch (error) {
console.error(`${colors.red}[ERROR]${colors.reset} Failed to load routes file: ${colors.bold}${error instanceof Error ? error.message : String(error)}${colors.reset}`);
}
return routes;
}
function processLinkNode(node, type, className, options) {
if (options.wrapperTemplate) {
const wrappedNode = options.wrapperTemplate(node, type, className);
Object.assign(node, wrappedNode);
} else {
if (className) {
addClass(node, className);
}
}
}
function rehypeSmartLinks(options = {}) {
const opts = { ...defaultOptions, ...options };
return (tree, file) => {
const _siteUrl = file.data?.site?.url || "";
const availableRoutes = /* @__PURE__ */ new Set();
const routes = file.data?.astro?.routes || [];
for (const route of routes) {
if (route.route) {
availableRoutes.add(normalizePath(route.route));
}
}
if (availableRoutes.size === 0) {
const routesFromFile = loadRoutesFromFile(opts.routesFile || defaultOptions.routesFile);
routesFromFile.forEach((route) => {
availableRoutes.add(route);
});
}
if (availableRoutes.size === 0) {
console.warn(`${colors.yellow}[WARNING]${colors.reset} No routes found. Consider running generateRoutesFile() before building.`);
const scannedRoutes = scanDirectoryForRoutes(opts.publicDir || defaultOptions.publicDir, "", /* @__PURE__ */ new Set(), opts);
scannedRoutes.forEach((route) => {
availableRoutes.add(route);
});
}
visit(tree, "element", (node) => {
if (!isElement(node, "a") || !node.properties)
return;
const href = node.properties.href;
if (!href)
return;
try {
const isExternal = href.startsWith("http") || href.startsWith("//");
if (isExternal) {
if (opts.customExternalLinkTransform) {
opts.customExternalLinkTransform(node);
return;
}
processLinkNode(node, "external", opts.externalLinkClass, opts);
node.properties.target = opts.target;
node.properties.rel = opts.rel;
if (opts.content && !opts.wrapperTemplate) {
const contentNode = {
type: "element",
tagName: "span",
properties: { className: opts.contentClass ? [opts.contentClass] : [] },
children: [{
type: "text",
value: opts.content.value
}]
};
node.children.push(contentNode);
}
} else {
let path = href;
if (path.startsWith("/")) {
path = path.substring(1);
}
const normalizedPath = normalizePath(path);
const pathExists = availableRoutes.has(normalizedPath) || availableRoutes.has(`${normalizedPath}/index`) || (path === "/" || path === "");
if (pathExists) {
if (opts.customInternalLinkTransform) {
opts.customInternalLinkTransform(node);
return;
}
processLinkNode(node, "internal", opts.internalLinkClass, opts);
} else {
let fileExists = false;
if (availableRoutes.size < 5) {
const publicDirPath = opts.publicDir || defaultOptions.publicDir;
try {
const publicPath = join(publicDirPath, path);
fileExists = existsSync(publicPath) || existsSync(`${publicPath}.html`) || existsSync(join(publicPath, "index.html"));
} catch {
}
}
if (fileExists) {
if (opts.customInternalLinkTransform) {
opts.customInternalLinkTransform(node);
return;
}
processLinkNode(node, "internal", opts.internalLinkClass, opts);
} else {
if (opts.customBrokenLinkTransform) {
opts.customBrokenLinkTransform(node);
return;
}
processLinkNode(node, "broken", opts.brokenLinkClass, opts);
}
}
}
} catch (error) {
console.error(`${colors.red}[ERROR]${colors.reset} Error in rehype-smart-links: ${colors.bold}${error instanceof Error ? error.message : String(error)}${colors.reset}`);
}
});
};
}
export {
generateRoutesFile,
rehypeSmartLinks
};