UNPKG

@scarlet-mesh/mcp-rhds

Version:

RHDS MCP Server - All-in-One Model Context Protocol server for Red Hat Design System components with manifest discovery, HTML validation, and developer tooling

242 lines (241 loc) 11.8 kB
import { BaseService } from './BaseService.js'; import { RHDS_STANDARDS, DEFAULT_PACKAGE } from '../constants/index.js'; export class ComponentService extends BaseService { manifestService; validationService; idGenerator; constructor(manifestService, validationService, idGenerator) { super(); this.manifestService = manifestService; this.validationService = validationService; this.idGenerator = idGenerator; } /** * Creates a new component instance based on the provided request. * Validates the component exists, generates an ID if not provided, * and builds the final HTML output with attributes, content, and slots. */ async createComponent(request, packageName = DEFAULT_PACKAGE) { const { tagName, id, attributes = {}, content, slots, includeAccessibility = true } = request; // Validate component exists const isValid = await this.manifestService.isComponentValid(tagName, packageName); if (!isValid) { const components = await this.manifestService.getComponents(packageName); const availableComponents = components.map(c => c.tagName).join(', '); return this.createErrorResponse(`Component "${tagName}" not found. Available components: ${availableComponents}`); } const components = await this.manifestService.getComponents(packageName); const component = components.find(c => c.tagName === tagName); // Generate or validate ID const componentId = id || this.idGenerator.generateId(tagName.replace('rh-', ''), 'component'); const idValidation = this.idGenerator.validateIdFormat(componentId, tagName); const warnings = []; if (!idValidation.isValid) { warnings.push(`ID validation issues: ${idValidation.errors.join(', ')}`); if (idValidation.suggestion) { warnings.push(`Suggested ID: ${idValidation.suggestion}`); } } // Build final attributes const finalAttributes = this.buildComponentAttributes(component, attributes, componentId, includeAccessibility); // Validate attributes const attrValidation = this.validationService.validateAttributes(component, finalAttributes); if (attrValidation.invalid.length > 0) { warnings.push(`Unknown attributes: ${attrValidation.invalid.join(', ')}`); } // Build HTML const html = this.buildComponentHtml(tagName, finalAttributes, content, slots); return this.createSuccessResponse(html, warnings.length > 0 ? warnings : undefined); } /** * Retrieves the template for a specific component by its tag name. * Validates the component exists, and generates a template with required attributes, * optional attributes, and accessibility requirements. */ async getTemplate(tagName, options = {}, packageName = DEFAULT_PACKAGE) { const { includeOptional = false, includeAccessibility = true } = options; const isValid = await this.manifestService.isComponentValid(tagName, packageName); if (!isValid) { const components = await this.manifestService.getComponents(packageName); const availableComponents = components.map(c => c.tagName).join(', '); return this.createErrorResponse(`Component "${tagName}" not found. Available components: ${availableComponents}`); } const components = await this.manifestService.getComponents(packageName); const component = components.find(c => c.tagName === tagName); const template = this.createComponentTemplate(component, includeOptional, includeAccessibility); return this.createSuccessResponse(template); } /** * Lists all components available in the specified package, optionally filtering by category or search term. * Returns a list of components with their details. */ async listComponents(options = {}, packageName = DEFAULT_PACKAGE) { const { category, search } = options; let components = await this.manifestService.getComponents(packageName); if (category) { components = components.filter(c => c.category === category); } if (search) { const searchLower = search.toLowerCase(); components = components.filter(c => c.name.toLowerCase().includes(searchLower) || c.tagName.toLowerCase().includes(searchLower) || c.description.toLowerCase().includes(searchLower)); } return this.createSuccessResponse(components); } /** * Creates a composition of multiple components, wrapping them in a specified container element if provided. * Validates each component, generates IDs, and builds the final HTML output. */ async createComposition(components, options = {}, packageName = DEFAULT_PACKAGE) { const { wrapperElement, wrapperId, wrapperClass } = options; const validationErrors = []; const suggestions = []; const componentHtmlParts = []; for (const [index, reqComp] of components.entries()) { const isValid = await this.manifestService.isComponentValid(reqComp.tagName, packageName); if (!isValid) { validationErrors.push(`Unknown component: ${reqComp.tagName}`); continue; } const result = await this.createComponent({ ...reqComp, id: reqComp.id || this.idGenerator.generateId(reqComp.tagName.replace('rh-', ''), 'component', index > 0 ? index.toString() : undefined) }, packageName); if (!result.success) { validationErrors.push(`Error creating ${reqComp.tagName}: ${result.error}`); continue; } componentHtmlParts.push(result.data); if (result.warnings) { suggestions.push(...result.warnings.map(w => `${reqComp.tagName}: ${w}`)); } } if (validationErrors.length > 0) { return this.createErrorResponse(`Composition creation failed: ${validationErrors.join(', ')}`); } let compositionHtml = componentHtmlParts.join('\n '); if (wrapperElement) { compositionHtml = this.wrapInContainer(compositionHtml, wrapperElement, wrapperId, wrapperClass); } return this.createSuccessResponse(compositionHtml, suggestions.length > 0 ? suggestions : undefined); } /** * Builds the final attributes for a component, including required, optional, and accessibility attributes. * Merges user-provided attributes with defaults and ensures all required attributes are present. */ buildComponentAttributes(component, userAttributes, id, includeAccessibility) { const finalAttributes = { id }; // Add required attributes with defaults component.requiredAttributes.forEach(attrName => { if (!userAttributes[attrName]) { const defaults = RHDS_STANDARDS.attributeDefaults[component.tagName]; finalAttributes[attrName] = defaults?.[attrName] || 'value'; } }); // Add accessibility attributes if requested if (includeAccessibility) { const accessibilityAttrs = RHDS_STANDARDS.accessibilityRequirements[component.tagName] || []; accessibilityAttrs.forEach(attrName => { if (!userAttributes[attrName] && !finalAttributes[attrName]) { finalAttributes[attrName] = attrName === 'role' ? 'button' : 'Describe the component purpose'; } }); } // Merge user attributes Object.assign(finalAttributes, userAttributes); return finalAttributes; } /** * Builds the final HTML output for a component, including its tag name, attributes, content, and slots. * Constructs the HTML string based on the provided parameters. */ buildComponentHtml(tagName, attributes, content, slots) { const attrString = Object.entries(attributes) .map(([name, value]) => `${name}="${value}"`) .join(' '); let componentHTML = `<${tagName} ${attrString}>`; if (slots) { for (const [slotName, slotContent] of Object.entries(slots)) { if (slotName === 'default') { componentHTML += `\n ${slotContent}`; } else { componentHTML += `\n <div slot="${slotName}">${slotContent}</div>`; } } } else if (content) { componentHTML += `\n ${content}`; } else { componentHTML += '\n <!-- Component content here -->'; } componentHTML += `\n</${tagName}>`; return componentHTML; } /** * Creates a component template with required and optional attributes, slots, and accessibility requirements. * Generates a basic HTML structure for the component. */ createComponentTemplate(component, includeOptional, includeAccessibility) { const defaults = RHDS_STANDARDS.attributeDefaults[component.tagName] || {}; const accessibilityReqs = RHDS_STANDARDS.accessibilityRequirements[component.tagName] || []; const requiredAttributes = {}; const optionalAttributes = {}; // Add required attributes component.requiredAttributes.forEach(attr => { requiredAttributes[attr] = defaults[attr] || 'value'; }); // Add accessibility attributes as required if (includeAccessibility) { accessibilityReqs.forEach(attr => { if (!requiredAttributes[attr]) { requiredAttributes[attr] = attr === 'role' ? 'button' : 'description'; } }); } // Add optional attributes if (includeOptional) { component.attributes .filter(attr => !attr.required) .forEach(attr => { optionalAttributes[attr.name] = attr.default || 'value'; }); } // Generate basic template const id = this.idGenerator.generateId(component.tagName.replace('rh-', ''), 'component'); const requiredAttrs = Object.entries(requiredAttributes) .map(([name, value]) => `${name}="${value}"`) .join(' '); const slots = component.slots.map(slot => slot.name); const slotContent = slots.length > 0 ? slots.map(slot => slot === 'default' ? 'Content here' : `<div slot="${slot}">Slot content</div>`).join('\n ') : 'Content here'; const template = `<${component.tagName} id="${id}"${requiredAttrs ? ' ' + requiredAttrs : ''}> ${slotContent} </${component.tagName}>`; return { name: component.name, tagName: component.tagName, template, requiredAttributes, optionalAttributes, slots, accessibilityRequirements: [...accessibilityReqs] }; } /** * Wraps the provided content in a specified container element with optional ID and class. * Constructs the HTML string for the wrapper element. */ wrapInContainer(content, wrapperElement, wrapperId, wrapperClass) { const wrapperAttrs = []; if (wrapperId) wrapperAttrs.push(`id="${wrapperId}"`); if (wrapperClass) wrapperAttrs.push(`class="${wrapperClass}"`); const wrapperAttrString = wrapperAttrs.length > 0 ? ' ' + wrapperAttrs.join(' ') : ''; return `<${wrapperElement}${wrapperAttrString}>\n ${content}\n</${wrapperElement}>`; } }