handoff-app
Version:
Automated documentation toolchain for building client side documentation from figma
516 lines (512 loc) • 22.2 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.runScaffold = void 0;
const p = __importStar(require("@clack/prompts"));
const chalk_1 = __importDefault(require("chalk"));
const fs_extra_1 = __importDefault(require("fs-extra"));
const path_1 = __importDefault(require("path"));
// Constants
const CONFIG_FILES = ['handoff.config.json', 'handoff.config.js', 'handoff.config.cjs'];
const COMPONENTS_DIR = 'components';
const DEFAULT_VERSION = '1.0.0';
const DEFAULT_GROUPS = [
{ value: 'Atomic Elements', label: 'Atomic Elements' },
{ value: 'Forms', label: 'Forms' },
{ value: 'Navigation', label: 'Navigation' },
{ value: 'Layout', label: 'Layout' },
{ value: 'Feedback', label: 'Feedback' },
{ value: 'custom', label: 'Custom...' },
];
/**
* Convert a string to Title Case
*/
const toTitleCase = (str) => {
return str
.replace(/[-_]/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase());
};
/**
* Generate the component JS stub content
*/
const generateComponentStub = (config) => {
// Build entries object with relative paths to generated files
const entriesLines = [];
if (config.generateTsx) {
entriesLines.push(` template: './${config.name}.tsx',`);
}
if (config.generateScss) {
entriesLines.push(` scss: './${config.name}.scss',`);
}
const entriesBlock = entriesLines.length > 0
? ` entries: {\n${entriesLines.join('\n')}\n },\n`
: '';
return `/** @type {import('handoff-app').Component} */
module.exports = {
title: "${config.title}",
description: "${config.description}",
group: "${config.group}",
type: "element",
figmaComponentId: "${config.name}",
${entriesBlock}};
`;
};
/**
* Generate a basic React component stub with variant props
*/
const generateReactComponentStub = (name, variantProps) => {
const componentName = toTitleCase(name).replace(/\s/g, '');
// Build props interface
const propsLines = variantProps.map((prop) => ` ${prop}?: string;`);
propsLines.push(' children?: React.ReactNode;');
// Build destructured props
const destructuredProps = [...variantProps, 'children'].join(', ');
// Build display content showing variant values
const displayContent = variantProps.length > 0
? `{[${variantProps.join(', ')}].filter(Boolean).join(' • ') || children}`
: '{children}';
return `import React from 'react';
interface ${componentName}Props {
${propsLines.join('\n')}
}
const ${componentName}: React.FC<${componentName}Props> = ({ ${destructuredProps} }) => {
return <div>${displayContent}</div>;
};
export default ${componentName};
`;
};
/**
* Generate a basic SCSS stub
*/
const generateScssStub = (name) => {
return `// ${toTitleCase(name)} component styles
.${name} {
// Add your styles here
}
`;
};
/**
* Extract unique variant property names from component instances
*/
const extractVariantProps = (instances) => {
const propSet = new Set();
for (const instance of instances) {
if (instance.variantProperties) {
for (const [propName] of instance.variantProperties) {
propSet.add(propName);
}
}
}
return Array.from(propSet);
};
/**
* Get list of Figma components from the documentation object
*/
const getFigmaComponents = (handoff) => __awaiter(void 0, void 0, void 0, function* () {
const documentationObject = yield handoff.getDocumentationObject();
if (!(documentationObject === null || documentationObject === void 0 ? void 0 : documentationObject.components)) {
return [];
}
return Object.entries(documentationObject.components).map(([name, data]) => {
var _a;
return ({
name,
instanceCount: ((_a = data.instances) === null || _a === void 0 ? void 0 : _a.length) || 0,
variantProps: extractVariantProps(data.instances || []),
});
});
});
/**
* Get list of registered component IDs from runtime config
*/
const getRegisteredComponentIds = (handoff) => {
var _a, _b;
const components = ((_b = (_a = handoff.runtimeConfig) === null || _a === void 0 ? void 0 : _a.entries) === null || _b === void 0 ? void 0 : _b.components) || {};
return Object.keys(components);
};
/**
* Find unregistered components (in Figma but not locally defined)
*/
const findUnregisteredComponents = (figmaComponents, registeredIds) => {
const registeredSet = new Set(registeredIds.map((id) => id.toLowerCase()));
return figmaComponents.filter((component) => !registeredSet.has(component.name.toLowerCase()));
};
/**
* Count registered components that match Figma components
*/
const countMatchingRegisteredComponents = (figmaComponents, registeredIds) => {
const figmaSet = new Set(figmaComponents.map((c) => c.name.toLowerCase()));
return registeredIds.filter((id) => figmaSet.has(id.toLowerCase())).length;
};
/**
* Check if components will be auto-loaded by an existing entry
* e.g., if 'components' directory is listed, all subdirectories are auto-loaded
*/
const willComponentsAutoLoad = (existingPaths) => {
// Normalize paths for comparison
const normalizedPaths = existingPaths.map((p) => p.replace(/\/$/, '').toLowerCase());
// If 'components' directory itself is in the list, all components auto-load
return normalizedPaths.includes(COMPONENTS_DIR);
};
/**
* Format component paths as a properly indented array string for JS files
*/
const formatComponentsArray = (paths, indent = ' ') => {
if (paths.length === 0)
return '[]';
if (paths.length === 1)
return `['${paths[0]}']`;
return `[\n${paths.map((p) => `${indent}'${p}',`).join('\n')}\n${indent.slice(2)}]`;
};
/**
* Update handoff config file to add component paths
*/
const updateConfigFile = (handoff, componentNames) => __awaiter(void 0, void 0, void 0, function* () {
const configFile = CONFIG_FILES.find((file) => fs_extra_1.default.existsSync(path_1.default.resolve(handoff.workingPath, file)));
const newComponentPaths = componentNames.map((name) => `${COMPONENTS_DIR}/${name}`);
if (!configFile) {
// Create a new handoff.config.json
const newConfig = {
entries: {
components: newComponentPaths,
},
};
const configPath = path_1.default.resolve(handoff.workingPath, 'handoff.config.json');
yield fs_extra_1.default.writeJSON(configPath, newConfig, { spaces: 2 });
return { success: true, isJsConfig: false, configPath, skipped: false };
}
const configPath = path_1.default.resolve(handoff.workingPath, configFile);
if (configFile.endsWith('.json')) {
const config = yield fs_extra_1.default.readJSON(configPath);
// Initialize entries if not present
if (!config.entries) {
config.entries = {};
}
if (!config.entries.components) {
config.entries.components = [];
}
// Check if components will auto-load
if (willComponentsAutoLoad(config.entries.components)) {
return { success: true, isJsConfig: false, configPath, skipped: true };
}
// Add new component paths
const existingPaths = new Set(config.entries.components);
for (const componentPath of newComponentPaths) {
if (!existingPaths.has(componentPath)) {
config.entries.components.push(componentPath);
}
}
yield fs_extra_1.default.writeJSON(configPath, config, { spaces: 2 });
return { success: true, isJsConfig: false, configPath, skipped: false };
}
// For JS/CJS config files, try to update them programmatically
if (configFile.endsWith('.js') || configFile.endsWith('.cjs')) {
try {
let content = yield fs_extra_1.default.readFile(configPath, 'utf8');
// Check if entries.components already exists
const hasEntriesComponents = /entries\s*:\s*\{[\s\S]*?components\s*:/.test(content);
if (hasEntriesComponents) {
// Find and update the components array
// Match: components: [...] or components: ["..."]
const componentsArrayRegex = /(components\s*:\s*\[)([^\]]*)(\])/;
const match = content.match(componentsArrayRegex);
if (match) {
const existingContent = match[2].trim();
const existingPaths = existingContent
.split(',')
.map((s) => s.trim().replace(/['"]/g, ''))
.filter((s) => s.length > 0);
// Check if components will auto-load
if (willComponentsAutoLoad(existingPaths)) {
return { success: true, isJsConfig: true, configPath, skipped: true };
}
const existingSet = new Set(existingPaths);
const pathsToAdd = newComponentPaths.filter((p) => !existingSet.has(p));
if (pathsToAdd.length > 0) {
const allPaths = [...existingPaths, ...pathsToAdd];
// Format with proper indentation (detect existing indentation)
const indentMatch = content.match(/components\s*:\s*\[\s*\n(\s*)/);
const indent = indentMatch ? indentMatch[1] : ' ';
const formattedArray = formatComponentsArray(allPaths, indent);
content = content.replace(componentsArrayRegex, `components: ${formattedArray}`);
yield fs_extra_1.default.writeFile(configPath, content, 'utf8');
}
return { success: true, isJsConfig: true, configPath, skipped: false };
}
}
// If entries exists but no components, or no entries at all
// Try to add the entries.components section
const hasEntries = /entries\s*:\s*\{/.test(content);
if (hasEntries) {
// Add components to existing entries object with proper formatting
const formattedArray = formatComponentsArray(newComponentPaths);
content = content.replace(/(entries\s*:\s*\{)/, `$1\n components: ${formattedArray},`);
yield fs_extra_1.default.writeFile(configPath, content, 'utf8');
return { success: true, isJsConfig: true, configPath, skipped: false };
}
// No entries object, try to add it before the closing of module.exports
const formattedArray = formatComponentsArray(newComponentPaths);
const entriesBlock = ` entries: {\n components: ${formattedArray},\n },\n`;
// Try to insert after module.exports = {
if (content.includes('module.exports = {')) {
content = content.replace(/(module\.exports\s*=\s*\{)/, `$1\n${entriesBlock}`);
yield fs_extra_1.default.writeFile(configPath, content, 'utf8');
return { success: true, isJsConfig: true, configPath, skipped: false };
}
// Fallback: couldn't parse the JS config
return { success: false, isJsConfig: true, configPath, skipped: false };
}
catch (_a) {
return { success: false, isJsConfig: true, configPath, skipped: false };
}
}
return { success: false, isJsConfig: false, configPath, skipped: false };
});
/**
* Create component files
*/
const createComponentFiles = (handoff, config) => __awaiter(void 0, void 0, void 0, function* () {
const createdFiles = [];
const componentDir = path_1.default.resolve(handoff.workingPath, COMPONENTS_DIR, config.name, DEFAULT_VERSION);
// Ensure directory exists
yield fs_extra_1.default.ensureDir(componentDir);
// Create the main component JS file
const jsPath = path_1.default.join(componentDir, `${config.name}.js`);
yield fs_extra_1.default.writeFile(jsPath, generateComponentStub(config));
createdFiles.push(jsPath);
// Optionally create TSX file
if (config.generateTsx) {
const tsxPath = path_1.default.join(componentDir, `${config.name}.tsx`);
yield fs_extra_1.default.writeFile(tsxPath, generateReactComponentStub(config.name, config.variantProps));
createdFiles.push(tsxPath);
}
// Optionally create SCSS file
if (config.generateScss) {
const scssPath = path_1.default.join(componentDir, `${config.name}.scss`);
yield fs_extra_1.default.writeFile(scssPath, generateScssStub(config.name));
createdFiles.push(scssPath);
}
return createdFiles;
});
/**
* Display a preview of the component stub
*/
const displayComponentPreview = (config) => {
const stub = generateComponentStub(config);
const lines = stub.split('\n');
const preview = lines.slice(0, 12).join('\n') + '\n ...\n};';
p.note(preview, `Preview: ${config.name}.js`);
};
/**
* Main scaffold entry point
*/
const runScaffold = (handoff) => __awaiter(void 0, void 0, void 0, function* () {
p.intro(chalk_1.default.bgCyan.black(' Handoff Component Scaffold '));
// Step 1: Validate tokens.json exists
const tokensPath = handoff.getTokensFilePath();
if (!fs_extra_1.default.existsSync(tokensPath)) {
p.cancel(chalk_1.default.red('No tokens.json found. Please run "handoff-app fetch" first to fetch Figma components.'));
process.exit(1);
}
// Step 2: Analyze components
const spinner = p.spinner();
spinner.start('Analyzing Figma components...');
const figmaComponents = yield getFigmaComponents(handoff);
const registeredIds = getRegisteredComponentIds(handoff);
const unregisteredComponents = findUnregisteredComponents(figmaComponents, registeredIds);
const matchingCount = countMatchingRegisteredComponents(figmaComponents, registeredIds);
spinner.stop('Analysis complete');
// Display summary
p.log.info(`Found ${chalk_1.default.cyan(figmaComponents.length)} components in Figma, ` +
`${chalk_1.default.green(matchingCount)} already have local implementations.`);
if (unregisteredComponents.length === 0) {
p.outro(chalk_1.default.green('All Figma components have local implementations. Nothing to do!'));
return;
}
p.log.info(`${chalk_1.default.yellow(unregisteredComponents.length)} components need stubs.`);
// Step 3: Component selection
const selectedComponents = yield p.multiselect({
message: 'Select components to scaffold:',
options: unregisteredComponents.map((component) => ({
value: component.name,
label: `${component.name} (${component.instanceCount} variants)`,
})),
required: true,
});
if (p.isCancel(selectedComponents)) {
p.cancel('Scaffold cancelled.');
process.exit(0);
}
const componentConfigs = [];
// Create a map for quick lookup of variant props
const componentVariantMap = new Map(unregisteredComponents.map((c) => [c.name, c.variantProps]));
// Step 4: Configure each selected component
for (const componentName of selectedComponents) {
p.log.step(`Configure "${componentName}"`);
const title = yield p.text({
message: 'Title:',
initialValue: toTitleCase(componentName),
validate: (value) => {
if (!value.trim())
return 'Title is required';
},
});
if (p.isCancel(title)) {
p.cancel('Scaffold cancelled.');
process.exit(0);
}
const description = yield p.text({
message: 'Description:',
initialValue: '',
placeholder: 'Optional description for this component',
});
if (p.isCancel(description)) {
p.cancel('Scaffold cancelled.');
process.exit(0);
}
const group = yield p.select({
message: 'Group:',
options: [...DEFAULT_GROUPS],
});
if (p.isCancel(group)) {
p.cancel('Scaffold cancelled.');
process.exit(0);
}
let finalGroup = group;
if (group === 'custom') {
const customGroup = yield p.text({
message: 'Enter custom group name:',
validate: (value) => {
if (!value.trim())
return 'Group name is required';
},
});
if (p.isCancel(customGroup)) {
p.cancel('Scaffold cancelled.');
process.exit(0);
}
finalGroup = customGroup;
}
const generateTsx = yield p.confirm({
message: 'Generate .tsx React component template?',
initialValue: true,
});
if (p.isCancel(generateTsx)) {
p.cancel('Scaffold cancelled.');
process.exit(0);
}
const generateScss = yield p.confirm({
message: 'Generate .scss style file?',
initialValue: true,
});
if (p.isCancel(generateScss)) {
p.cancel('Scaffold cancelled.');
process.exit(0);
}
const config = {
name: componentName,
title: title,
description: description || '',
group: finalGroup,
generateTsx: generateTsx,
generateScss: generateScss,
variantProps: componentVariantMap.get(componentName) || [],
};
componentConfigs.push(config);
// Display preview
displayComponentPreview(config);
}
// Step 5: Config update prompt
const updateConfig = yield p.confirm({
message: 'Update handoff config file to include these components?',
initialValue: true,
});
if (p.isCancel(updateConfig)) {
p.cancel('Scaffold cancelled.');
process.exit(0);
}
// Step 6: Generate files
const generationSpinner = p.spinner();
generationSpinner.start('Creating component files...');
const allCreatedFiles = [];
const componentNames = [];
for (const config of componentConfigs) {
const createdFiles = yield createComponentFiles(handoff, config);
allCreatedFiles.push(...createdFiles);
componentNames.push(config.name);
}
// Update config if requested
let configUpdateResult = null;
if (updateConfig) {
configUpdateResult = yield updateConfigFile(handoff, componentNames);
if (!configUpdateResult.success) {
const relativePath = path_1.default.relative(handoff.workingPath, configUpdateResult.configPath);
p.log.warn(`Could not automatically update ${relativePath}. Please manually add these paths to entries.components:`);
for (const name of componentNames) {
console.log(chalk_1.default.yellow(` 'components/${name}'`));
}
}
}
generationSpinner.stop('Files created successfully');
// Display summary
p.log.success('Created files:');
for (const file of allCreatedFiles) {
const relativePath = path_1.default.relative(handoff.workingPath, file);
console.log(chalk_1.default.dim(` ${relativePath}`));
}
if (updateConfig && (configUpdateResult === null || configUpdateResult === void 0 ? void 0 : configUpdateResult.success)) {
const configFileName = path_1.default.basename(configUpdateResult.configPath);
if (configUpdateResult.skipped) {
p.log.info(`Config already includes 'components' directory - new components will auto-load`);
}
else {
p.log.success(`Updated ${configFileName} with component paths`);
}
}
p.outro(chalk_1.default.green(`Successfully created ${componentConfigs.length} component stub(s)!`));
});
exports.runScaffold = runScaffold;