UNPKG

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