@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
JavaScript
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}>`;
}
}