import { existsSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import type { ParsedFigmaFile } from './parser'; import type { FigmaCodexConfig } from './config'; import type { ComponentEntry, PropDefinition, SubComponentEntry, } from './schema'; function resolveSourceFile( importSource: string, figmaFilePath: string, ): string | null { const baseDir = dirname(figmaFilePath); const candidates = [ join(baseDir, importSource + '.tsx'), join(baseDir, importSource + '.ts'), join(baseDir, importSource, 'index.ts'), join(baseDir, importSource, 'index.tsx'), join(baseDir, importSource), ]; for (const c of candidates) { if (existsSync(c)) return c; } return null; } function resolveImplementationFile(sourceFile: string): string { const content = readFileSync(sourceFile, 'utf-8'); // If this is an index that re-exports from a single file, resolve to that file // Use a simple match without end-of-string anchor to handle trailing newlines const reExportMatch = content.match(/from\s+['"]\.\/(\w+)['"]/); if (reExportMatch) { const implFile = join(dirname(sourceFile), reExportMatch[1] + '.tsx'); if (existsSync(implFile)) return implFile; } return sourceFile; } function inferTypeFromDescription(description: string): string { const desc = description.trim(); // Direct type keyword at start const directType = desc.match( /^(boolean|string|number|string\[\]|number\[\])(\s*[,.]|$)/, ); if (directType) return directType[1]; // Parenthesized type at end: "description (boolean)" const parenEnd = desc.match(/\(([a-zA-Z[\]]+)\)\s*$/); if (parenEnd && /^(boolean|string|number)(\[\])?$/.test(parenEnd[1])) { return parenEnd[1]; } // Union of string literals: 'a' | 'b' | 'c' — possibly with trailing " (default: ...)" if (/^'[^']+'(\s*\|\s*'[^']+')+/.test(desc)) { const match = desc.match(/^('(?:[^']+)'\s*(?:\|\s*'(?:[^']+)'\s*)*)/); if (match) return match[1].trim(); } // Callback: "callback (...) => void" if (desc.startsWith('callback')) { const rest = desc.replace(/^callback\s+/, ''); return rest || '(...args: unknown[]) => void'; } return 'string'; } function extractPropsFromFigmaJsdoc(figmaFilePath: string): PropDefinition[] { if (!existsSync(figmaFilePath)) return []; const content = readFileSync(figmaFilePath, 'utf-8'); const props: PropDefinition[] = []; const lines = content.split('\n'); let inPropsSection = false; for (const line of lines) { const trimmed = line.trim(); if (!trimmed.startsWith('//')) { if (inPropsSection) break; // left the comment block continue; } // Strip the leading `//` (and optional single space) const commentContent = trimmed.replace(/^\/\/\s?/, ''); if (/^Key props/i.test(commentContent)) { inPropsSection = true; continue; } if (!inPropsSection) continue; // Match " propName — description" (em-dash, en-dash, or hyphen) const propMatch = commentContent.match(/^\s+(\w+)\s+[—–-]+\s+(.+)$/); if (propMatch) { const [, name, description] = propMatch; const type = inferTypeFromDescription(description); props.push({ name, type, required: false, description: description.trim(), }); } } return props; } function extractProps( sourceContent: string, figmaFilePath?: string, ): PropDefinition[] { const props: PropDefinition[] = []; // Step 1: Find the Props interface and extract its full body using balanced-brace tracking const ifaceRe = /interface\s+\w+Props\s*\{/g; const startMatch = ifaceRe.exec(sourceContent); if (!startMatch) { // Fallback: no interface found — read Key props section from the .figma.tsx JSDoc if (figmaFilePath) return extractPropsFromFigmaJsdoc(figmaFilePath); return props; } // Find the opening { of the interface body const openBrace = sourceContent.indexOf( '{', startMatch.index + startMatch[0].length - 1, ); let depth = 0; let closeBrace = openBrace; for (let i = openBrace; i < sourceContent.length; i++) { if (sourceContent[i] === '{') depth++; else if (sourceContent[i] === '}') { depth--; if (depth === 0) { closeBrace = i; break; } } } const body = sourceContent.slice(openBrace + 1, closeBrace); // Step 2: Parse each prop using a position-based scanner let pos = 0; while (pos < body.length) { // Skip whitespace while (pos < body.length && /\s/.test(body[pos])) pos++; if (pos >= body.length) break; // Try to match JSDoc comment /** ... */ let jsdoc: string | undefined; if (body.startsWith('/**', pos)) { const endDoc = body.indexOf('*/', pos); if (endDoc !== -1) { jsdoc = body .slice(pos + 3, endDoc) .trim() .replace(/\n\s*\*\s*/g, ' '); pos = endDoc + 2; while (pos < body.length && /\s/.test(body[pos])) pos++; } } // Match prop name followed by optional `?` and `:` const nameMatch = /^(\w+)(\?)?:/.exec(body.slice(pos)); if (!nameMatch) { // Not a prop — skip to next semicolon or newline const next = body.indexOf('\n', pos); pos = next === -1 ? body.length : next + 1; continue; } const [fullMatch, name, optional] = nameMatch; pos += fullMatch.length; if (name === 'children') { // Skip children — find the next semicolon at depth 0 let bracketDepth = 0; while (pos < body.length) { const ch = body[pos]; if (ch === '(' || ch === '{') bracketDepth++; else if ((ch === ')' || ch === '}') && bracketDepth > 0) bracketDepth--; else if (ch === ';' && bracketDepth === 0) { pos++; break; } pos++; } continue; } // Skip whitespace after the colon while (pos < body.length && body[pos] === ' ') pos++; // Read the type: track () and {} depth, stop at ; when depth === 0 const typeStart = pos; let bracketDepth = 0; while (pos < body.length) { const ch = body[pos]; if (ch === '(' || ch === '{') { bracketDepth++; } else if ((ch === ')' || ch === '}') && bracketDepth > 0) { bracketDepth--; } else if (ch === ';' && bracketDepth === 0) { break; } else if (ch === '\n' && bracketDepth === 0) { break; } pos++; } const typeRaw = body.slice(typeStart, pos).trim(); // Normalize multi-line types: collapse internal whitespace sequences const typeNormalized = typeRaw.replace(/\s+/g, ' '); // Skip the terminating ; or newline if (pos < body.length && (body[pos] === ';' || body[pos] === '\n')) pos++; if (!name || !typeNormalized) continue; props.push({ name, type: typeNormalized, required: !optional, description: jsdoc?.trim(), }); } return props; } function extractSubComponents(sourceContent: string): SubComponentEntry[] { const subs = new Map(); let m: RegExpExecArray | null; // Pattern 1: withProvider/withContext(ark.tagName, ...) — Park UI ark factory const arkRe = /export\s+const\s+(\w+)\s+=\s+with(?:Provider|Context)\(ark\.(\w+)/g; while ((m = arkRe.exec(sourceContent)) !== null) { subs.set(m[1], { name: m[1], element: m[2] }); } // Pattern 2: withProvider/withContext(Namespace.Sub, 'slotName') — styleContext pattern // Used by RadioGroup, Switch, and similar Ark UI compound components const styleCtxRe = /export\s+const\s+(\w+)\s+=\s+with(?:Provider|Context)\(\s*\w+\.\w+\s*,\s*['"](\w+)['"]/g; while ((m = styleCtxRe.exec(sourceContent)) !== null) { if (!subs.has(m[1])) subs.set(m[1], { name: m[1], element: m[2] }); } // Pattern 3: createStyledComponent(Namespace.Sub, 'slotName', ...) — custom styled wrapper // Used by Slider and similar components with manual style context const styledRe = /export\s+const\s+(\w+)\s+=\s+createStyledComponent\(\s*\w+\.\w+\s*,\s*['"](\w+)['"]/g; while ((m = styledRe.exec(sourceContent)) !== null) { if (!subs.has(m[1])) subs.set(m[1], { name: m[1], element: m[2] }); } // Pattern 4: forwardRef — typed forward ref exports const forwardRefRe = /export\s+const\s+(\w+)\s+=\s+forwardRef; if (!exports) return undefined; const name = parsed.componentName; for (const key of Object.keys(exports)) { if (key === `./${name}` || key.endsWith(`/${name}`)) { return `${config.packageName}${key.slice(1)}`; } } } catch { // ignore } return undefined; } export function resolveComponent( parsed: ParsedFigmaFile, config: FigmaCodexConfig, ): ComponentEntry { // Determine project root: walk up from figma file until we find package.json let projectRoot = dirname(parsed.filePath); for (let i = 0; i < 8; i++) { if (existsSync(join(projectRoot, 'package.json'))) break; projectRoot = dirname(projectRoot); } const sourceFile = resolveSourceFile(parsed.importSource, parsed.filePath); let sourceContent = ''; if (sourceFile && existsSync(sourceFile)) { sourceContent = readFileSync(sourceFile, 'utf-8'); // For index files, look for the actual implementation if (sourceFile.endsWith('index.ts') || sourceFile.endsWith('index.tsx')) { const implFile = resolveImplementationFile(sourceFile); if (implFile !== sourceFile && existsSync(implFile)) { sourceContent = readFileSync(implFile, 'utf-8'); } } } // For composite components, types may be in a separate types.ts file let typesContent = sourceContent; if (sourceFile) { const typesFile = join(dirname(sourceFile), 'types.ts'); if (existsSync(typesFile)) { typesContent = readFileSync(typesFile, 'utf-8'); } } const componentType = classifyComponent(parsed, sourceContent); const props = extractProps(typesContent, parsed.filePath); const subComponents = componentType === 'compound' ? extractSubComponents(sourceContent) : undefined; const subpath = resolveSubpath(parsed, config, projectRoot); const isNamespace = parsed.importStyle === 'namespace'; const importName = isNamespace ? `* as ${parsed.componentName}` : `{ ${parsed.componentName} }`; const importFrom = subpath ?? `${config.packageName}/${parsed.componentName}`; const primaryImport = `import ${importName} from '${importFrom}'`; const namedExports = isNamespace ? (subComponents?.map((s) => `${parsed.componentName}.${s.name}`) ?? []) : [parsed.componentName]; const sourcePath = sourceFile ? sourceFile.replace(projectRoot + '/', '') : parsed.importSource; return { name: parsed.componentName, type: componentType, figma: { fileKey: parsed.figmaFileKey, nodeId: parsed.figmaNodeId, url: parsed.figmaUrl, }, imports: { primary: primaryImport, namedExports, subpath, }, props, subComponents, example: parsed.example, sourcePath, tokens: parsed.tokens, }; }