UNPKG

@heilgar/shadcn-ui-mcp-server

Version:

MCP server for shadcn/ui component references

221 lines (220 loc) 8.85 kB
import { load } from "cheerio"; export const RETRY_ATTEMPTS = 3; export const RETRY_DELAY_MS = 500; export const BASE_URL = "https://ui.shadcn.com"; export const RAW_GITHUB_URL = "https://raw.githubusercontent.com/shadcn-ui/ui/refs/heads/main/apps"; export const BLOCK_PAGES = [ `${BASE_URL}/blocks/sidebar`, `${BASE_URL}/blocks/authentication`, ]; export const RUNTIME_REPLACEMENTS = { pnpm: 'pnpm dlx', yarn: 'yarn dlx', bun: 'bunx' }; export const FRONTMATTER_REGEX = /^---\n([\s\S]*?)\n---/; export const DESCRIPTION_PATTERNS = [ /description:\s*["']([^"']+)["']/, /description:\s*([^\n]+)/, /description\s*:\s*["']([^"']+)["']/ ]; export const FIRST_PARAGRAPH_REGEX = /---\n[\s\S]*?\n---\n\n([^\n]+)/; export const LINKS_REGEX = /links:\n([\s\S]*?)(?=\n\w|$)/; export const CLI_COMMAND_REGEX = /```bash\nnpx shadcn@latest add [^\n]+\n```/; export const USAGE_REGEX = /## Usage\n\n([\s\S]*?)(?=\n## |$)/; export const CODE_BLOCKS_REGEX = /```(?:tsx|ts|jsx|js)([\s\S]*?)```/g; export const CODE_BLOCK_CLEANUP_REGEX = /```(?:tsx|ts|jsx|js)\n|```$/g; export const resourceCache = new Map(); const loadCheerio = (html) => load(html, { decodeEntities: true }); export const validateRuntime = (runtime) => !runtime || ['npm', 'pnpm', 'yarn', 'bun'].includes(runtime); export const extractDescription = (frontmatter, mdxContent) => { for (const pattern of DESCRIPTION_PATTERNS) { const match = frontmatter.match(pattern); if (match) return match[1].trim(); } const firstParagraphMatch = mdxContent.match(FIRST_PARAGRAPH_REGEX); return firstParagraphMatch ? firstParagraphMatch[1].trim() : ''; }; export const extractLinks = (frontmatter) => { const links = []; const linksMatch = frontmatter.match(LINKS_REGEX); if (linksMatch) { const linksContent = linksMatch[1]; const docLinkMatch = linksContent.match(/doc:\s*([^\n]+)/); const apiLinkMatch = linksContent.match(/api:\s*([^\n]+)/); if (docLinkMatch) links.push(docLinkMatch[1].trim()); if (apiLinkMatch) links.push(apiLinkMatch[1].trim()); } return links; }; export const getCliCommand = (cliCommand, runtime) => { if (!runtime || runtime === 'npm') return cliCommand; return cliCommand.replace('npx', RUNTIME_REPLACEMENTS[runtime]); }; export const createResponse = (text, isError = false, mimeType) => ({ content: [{ type: "text", text, ...(mimeType && { mimeType }) }], ...(isError && { isError }) }); export const handleError = (error, prefix) => createResponse(`${prefix}: ${error instanceof Error ? error.message : String(error)}`, true); export async function fetchWithRetry(url, retries = RETRY_ATTEMPTS, delay = RETRY_DELAY_MS) { try { const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP error ${response.status}: ${response.statusText}`); } return response; } catch (error) { if (retries <= 1) throw error; await new Promise(resolve => setTimeout(resolve, delay)); return fetchWithRetry(url, retries - 1, delay * 2); // Exponential backoff } } async function cacheResource(key, fetchFn, cache) { // Check cache first if (cache.has(key)) { return cache.get(key); } try { const data = await fetchFn(); cache.set(key, data); return data; } catch (error) { throw new Error(`Failed to fetch data for key '${key}': ${error instanceof Error ? error.message : String(error)}`); } } export async function fetchAndCache(key, fetchFn, transformFn) { try { const rawData = await fetchFn(); const transformedData = transformFn(rawData); transformedData.forEach(data => resourceCache.set(data.name, data)); return transformedData; } catch (error) { throw new Error(`Failed to fetch and transform data for key '${key}': ${error instanceof Error ? error.message : String(error)}`); } } export async function fetchAndCacheComponentData(component) { if (!component || typeof component !== 'string') { throw new Error('Invalid component name'); } // Sanitize component name const sanitizedComponent = component.replace(/[^a-zA-Z0-9-_]/g, ''); if (sanitizedComponent !== component) { throw new Error(`Invalid component name: ${component}`); } // Check cache if (resourceCache.has(component)) { return resourceCache.get(component); } const docSubPath = `www/content/docs/components`; const url = `${RAW_GITHUB_URL}/${docSubPath}/${component}.mdx`; const transformComponentData = (mdxContent) => { const frontmatterMatch = mdxContent.match(FRONTMATTER_REGEX); const frontmatter = frontmatterMatch ? frontmatterMatch[1] : ''; const description = extractDescription(frontmatter, mdxContent); const links = extractLinks(frontmatter); const cliCommandMatch = mdxContent.match(CLI_COMMAND_REGEX); const cliCommand = cliCommandMatch ? cliCommandMatch[0].replace(/```bash\n|\n```/g, '').trim() : undefined; let commands = undefined; if (cliCommand) { commands = [{ npm: cliCommand, pnpm: getCliCommand(cliCommand, 'pnpm'), yarn: getCliCommand(cliCommand, 'yarn'), bun: getCliCommand(cliCommand, 'bun') }]; } return [{ name: component, description, doc: mdxContent, commands, links: links.length > 0 ? links : undefined, isBlock: false }]; }; const [componentData] = await fetchAndCache(component, async () => { const response = await fetchWithRetry(url); return response.text(); }, transformComponentData); return componentData; } export async function fetchAndCacheBlocks() { const transformBlocks = (blockPages) => { const allBlocks = blockPages.flat(); return allBlocks.map((block) => ({ name: block.name, description: block.description, doc: block.doc, commands: [{ npm: block.command, pnpm: getCliCommand(block.command, 'pnpm'), yarn: getCliCommand(block.command, 'yarn'), bun: getCliCommand(block.command, 'bun') }], isBlock: true })); }; return fetchAndCache('blocks', async () => Promise.all(BLOCK_PAGES.map(parseBlocksFromPage)), transformBlocks); } export async function parseBlocksFromPage(url) { if (!url || !url.startsWith('https://')) { throw new Error(`Invalid URL: ${url}`); } try { const response = await fetchWithRetry(url); const html = await response.text(); const $ = loadCheerio(html); const blocks = []; $('.container-wrapper.flex-1 div[id]').each((_, el) => { const $block = $(el); const id = $block.attr('id'); if (id && !id.startsWith('radix-')) { const anchor = $block.find('div.flex.w-full.items-center.gap-2.md\\:pr-\\[14px\\] > a'); const description = anchor.text().trim(); const command = $block.find('div.flex.w-full.items-center.gap-2.md\\:pr-\\[14px\\] > div.ml-auto.hidden.items-center.gap-2.md\\:flex > div.flex.h-7.items-center.gap-1.rounded-md.border.p-\\[2px\\] > button > span').text().trim(); const doc = $block.find('code').first().text().trim(); blocks.push({ name: id, description, command, doc }); } }); if (blocks.length === 0) { console.error(`Warning: No blocks found at ${url}`); } return blocks; } catch (error) { throw new Error(`Failed to parse blocks from ${url}: ${error instanceof Error ? error.message : String(error)}`); } } export function parseComponentsFromHtml(html) { if (!html || typeof html !== 'string') { throw new Error('Invalid HTML content'); } try { const $ = loadCheerio(html); const components = $('a[href^="/docs/components/"]') .map((_, el) => { const href = $(el).attr('href'); return href?.split('/').pop(); }) .get() .filter((name) => Boolean(name)) .sort(); if (components.length === 0) { console.error('Warning: No components found in HTML'); } return components; } catch (error) { throw new Error(`Failed to parse components: ${error instanceof Error ? error.message : String(error)}`); } }