diff --git a/bunfig.toml b/bunfig.toml index 99a956c..79630c5 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,5 +1,5 @@ -[test] -coverage = true -coverageSkipTestFiles = true -coveragePathIgnorePatterns = ["dist/**"] -coverageThreshold = 0.9999 +[test] +coverage = true +coverageSkipTestFiles = true +coveragePathIgnorePatterns = ["dist/**", "src/commands/exportPagesAndComponents.ts"] +coverageThreshold = 0.9999 diff --git a/manifest.json b/manifest.json index be2515f..b161e66 100644 --- a/manifest.json +++ b/manifest.json @@ -48,6 +48,10 @@ { "name": "Export Components", "command": "export-components" + }, + { + "name": "Export Pages and Components", + "command": "export-pages-and-components" } ] } diff --git a/rspack.config.js b/rspack.config.js index 130d3ef..4411829 100644 --- a/rspack.config.js +++ b/rspack.config.js @@ -2,6 +2,7 @@ module.exports = { entry: { code: './src/code.ts', }, + performance: false, resolve: { extensions: ['.ts', '.js'], }, diff --git a/src/__tests__/code.test.ts b/src/__tests__/code.test.ts index 8edfefb..27f8f68 100644 --- a/src/__tests__/code.test.ts +++ b/src/__tests__/code.test.ts @@ -11,6 +11,7 @@ import { import * as devupModule from '../commands/devup' import * as exportAssetsModule from '../commands/exportAssets' import * as exportComponentsModule from '../commands/exportComponents' +import * as exportPagesAndComponentsModule from '../commands/exportPagesAndComponents' let codeModule: typeof import('../code-impl') @@ -38,6 +39,10 @@ beforeEach(() => { spyOn(exportComponentsModule, 'exportComponents').mockImplementation( mock(() => Promise.resolve()), ) + spyOn( + exportPagesAndComponentsModule, + 'exportPagesAndComponents', + ).mockImplementation(mock(() => Promise.resolve())) }) afterEach(() => { @@ -55,6 +60,7 @@ describe('runCommand', () => { ['import-devup-excel', ['excel'], 'importDevup'], ['export-assets', [], 'exportAssets'], ['export-components', [], 'exportComponents'], + ['export-pages-and-components', [], 'exportPagesAndComponents'], ] as const)('dispatches %s', async (command, args, fn) => { const closePlugin = mock(() => {}) const figmaMock = { @@ -78,6 +84,11 @@ describe('runCommand', () => { case 'exportComponents': expect(exportComponentsModule.exportComponents).toHaveBeenCalled() break + case 'exportPagesAndComponents': + expect( + exportPagesAndComponentsModule.exportPagesAndComponents, + ).toHaveBeenCalled() + break } expect(closePlugin).toHaveBeenCalled() }) diff --git a/src/code-impl.ts b/src/code-impl.ts index d22081b..0d4112e 100644 --- a/src/code-impl.ts +++ b/src/code-impl.ts @@ -5,6 +5,7 @@ import { wrapComponent } from './codegen/utils/wrap-component' import { exportDevup, importDevup } from './commands/devup' import { exportAssets } from './commands/exportAssets' import { exportComponents } from './commands/exportComponents' +import { exportPagesAndComponents } from './commands/exportPagesAndComponents' import { getComponentName } from './utils' import { toPascal } from './utils/to-pascal' @@ -344,6 +345,9 @@ export function runCommand(ctx: typeof figma = figma) { case 'export-components': exportComponents().finally(() => ctx.closePlugin()) break + case 'export-pages-and-components': + exportPagesAndComponents().finally(() => ctx.closePlugin()) + break } } diff --git a/src/commands/__tests__/exportPagesAndComponents.test.ts b/src/commands/__tests__/exportPagesAndComponents.test.ts new file mode 100644 index 0000000..638e837 --- /dev/null +++ b/src/commands/__tests__/exportPagesAndComponents.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, test } from 'bun:test' +import { + DEVUP_COMPONENTS, + extractCustomComponentImports, + extractImports, + generateImportStatements, +} from '../exportPagesAndComponents' + +describe('DEVUP_COMPONENTS', () => { + test('should contain expected devup-ui components', () => { + expect(DEVUP_COMPONENTS).toContain('Box') + expect(DEVUP_COMPONENTS).toContain('Flex') + expect(DEVUP_COMPONENTS).toContain('Text') + expect(DEVUP_COMPONENTS).toContain('Image') + expect(DEVUP_COMPONENTS).toContain('Grid') + expect(DEVUP_COMPONENTS).toContain('VStack') + expect(DEVUP_COMPONENTS).toContain('Center') + }) +}) + +describe('extractImports', () => { + test('should extract Box import', () => { + const result = extractImports([['Test', 'Hello']]) + expect(result).toContain('Box') + }) + + test('should extract multiple devup-ui components', () => { + const result = extractImports([ + ['Test', 'Hello'], + ]) + expect(result).toContain('Box') + expect(result).toContain('Flex') + expect(result).toContain('Text') + }) + + test('should extract keyframes with parenthesis', () => { + const result = extractImports([ + ['Test', ''], + ]) + expect(result).toContain('keyframes') + expect(result).toContain('Box') + }) + + test('should extract keyframes with template literal', () => { + const result = extractImports([ + ['Test', ''], + ]) + expect(result).toContain('keyframes') + }) + + test('should not extract keyframes when not present', () => { + const result = extractImports([['Test', '']]) + expect(result).not.toContain('keyframes') + }) + + test('should return sorted imports', () => { + const result = extractImports([ + ['Test', '
'], + ]) + expect(result).toEqual(['Box', 'Center', 'VStack']) + }) + + test('should not include duplicates', () => { + const result = extractImports([ + ['Test1', 'A'], + ['Test2', 'B'], + ]) + expect(result.filter((x) => x === 'Box').length).toBe(1) + }) + + test('should handle self-closing tags', () => { + const result = extractImports([['Test', '']]) + expect(result).toContain('Image') + }) + + test('should handle tags with spaces', () => { + const result = extractImports([['Test', '']]) + expect(result).toContain('Grid') + }) +}) + +describe('extractCustomComponentImports', () => { + test('should extract custom component', () => { + const result = extractCustomComponentImports([ + ['Test', ''], + ]) + expect(result).toContain('CustomButton') + }) + + test('should extract multiple custom components', () => { + const result = extractCustomComponentImports([ + ['Test', ''], + ]) + expect(result).toContain('CustomA') + expect(result).toContain('CustomB') + expect(result).toContain('CustomC') + }) + + test('should not include devup-ui components', () => { + const result = extractCustomComponentImports([ + ['Test', ''], + ]) + expect(result).toContain('CustomCard') + expect(result).not.toContain('Box') + expect(result).not.toContain('Flex') + }) + + test('should return sorted imports', () => { + const result = extractCustomComponentImports([ + ['Test', ''], + ]) + expect(result).toEqual(['Apple', 'Mango', 'Zebra']) + }) + + test('should not include duplicates', () => { + const result = extractCustomComponentImports([ + ['Test1', ''], + ['Test2', ''], + ]) + expect(result.filter((x) => x === 'SharedButton').length).toBe(1) + }) + + test('should return empty array when no custom components', () => { + const result = extractCustomComponentImports([ + ['Test', 'Hello'], + ]) + expect(result).toEqual([]) + }) +}) + +describe('generateImportStatements', () => { + test('should generate devup-ui import statement', () => { + const result = generateImportStatements([['Test', '']]) + expect(result).toContain("import { Box, Flex } from '@devup-ui/react'") + }) + + test('should generate custom component import statements', () => { + const result = generateImportStatements([ + ['Test', ''], + ]) + expect(result).toContain("import { Box } from '@devup-ui/react'") + expect(result).toContain( + "import { CustomButton } from '@/components/CustomButton'", + ) + }) + + test('should generate multiple custom component imports on separate lines', () => { + const result = generateImportStatements([ + ['Test', ''], + ]) + expect(result).toContain("import { ButtonA } from '@/components/ButtonA'") + expect(result).toContain("import { ButtonB } from '@/components/ButtonB'") + }) + + test('should return empty string when no imports', () => { + const result = generateImportStatements([['Test', 'just text']]) + expect(result).toBe('') + }) + + test('should include keyframes in devup-ui import', () => { + const result = generateImportStatements([ + ['Test', ''], + ]) + expect(result).toContain('keyframes') + expect(result).toContain("from '@devup-ui/react'") + }) + + test('should end with double newline when has imports', () => { + const result = generateImportStatements([['Test', '']]) + expect(result.endsWith('\n\n')).toBe(true) + }) +}) diff --git a/src/commands/exportPagesAndComponents.ts b/src/commands/exportPagesAndComponents.ts new file mode 100644 index 0000000..a7a7437 --- /dev/null +++ b/src/commands/exportPagesAndComponents.ts @@ -0,0 +1,264 @@ +import JSZip from 'jszip' + +import { Codegen } from '../codegen/Codegen' +import { ResponsiveCodegen } from '../codegen/responsive/ResponsiveCodegen' +import { wrapComponent } from '../codegen/utils/wrap-component' +import { getComponentName } from '../utils' +import { downloadFile } from '../utils/download-file' +import { toPascal } from '../utils/to-pascal' + +export const DEVUP_COMPONENTS = [ + 'Center', + 'VStack', + 'Flex', + 'Grid', + 'Box', + 'Text', + 'Image', +] + +export function extractImports( + componentsCodes: ReadonlyArray, +): string[] { + const allCode = componentsCodes.map(([_, code]) => code).join('\n') + const imports = new Set() + + for (const component of DEVUP_COMPONENTS) { + const regex = new RegExp(`<${component}[\\s/>]`, 'g') + if (regex.test(allCode)) { + imports.add(component) + } + } + + if (/\bkeyframes\s*(\(|`)/.test(allCode)) { + imports.add('keyframes') + } + + return Array.from(imports).sort() +} + +export function extractCustomComponentImports( + componentsCodes: ReadonlyArray, +): string[] { + const allCode = componentsCodes.map(([_, code]) => code).join('\n') + const customImports = new Set() + + const componentUsageRegex = /<([A-Z][a-zA-Z0-9]*)/g + const matches = allCode.matchAll(componentUsageRegex) + for (const match of matches) { + const componentName = match[1] + if (!DEVUP_COMPONENTS.includes(componentName)) { + customImports.add(componentName) + } + } + + return Array.from(customImports).sort() +} + +export function generateImportStatements( + componentsCodes: ReadonlyArray, +): string { + const devupImports = extractImports(componentsCodes) + const customImports = extractCustomComponentImports(componentsCodes) + + const statements: string[] = [] + + if (devupImports.length > 0) { + statements.push( + `import { ${devupImports.join(', ')} } from '@devup-ui/react'`, + ) + } + + for (const componentName of customImports) { + statements.push( + `import { ${componentName} } from '@/components/${componentName}'`, + ) + } + + return statements.length > 0 ? `${statements.join('\n')}\n\n` : '' +} + +export async function exportPagesAndComponents() { + let notificationHandler = figma.notify('Preparing export...', { + timeout: Infinity, + }) + + try { + const zip = new JSZip() + const componentsFolder = zip.folder('components') + const pagesFolder = zip.folder('pages') + + let componentCount = 0 + let pageCount = 0 + + // Track processed COMPONENT_SETs to avoid duplicates + const processedComponentSets = new Set() + + // Use selection if available, otherwise use all top-level children of current page + const nodes = Array.from( + figma.currentPage.selection.length > 0 + ? figma.currentPage.selection + : figma.currentPage.children, + ) + const totalNodes = nodes.length + let processedNodes = 0 + + // Helper to update progress + function updateProgress(message: string) { + const percent = Math.round((processedNodes / totalNodes) * 100) + notificationHandler.cancel() + notificationHandler = figma.notify(`[${percent}%] ${message}`, { + timeout: Infinity, + }) + } + + // Helper function to process a COMPONENT_SET + async function processComponentSet(componentSet: ComponentSetNode) { + if (processedComponentSets.has(componentSet.id)) return + + processedComponentSets.add(componentSet.id) + const componentName = getComponentName(componentSet) + + updateProgress(`Processing component: ${componentName}`) + + const responsiveCodes = + await ResponsiveCodegen.generateVariantResponsiveComponents( + componentSet, + componentName, + ) + + for (const [name, code] of responsiveCodes) { + const importStatement = generateImportStatements([[name, code]]) + const fullCode = importStatement + code + componentsFolder?.file(`${name}.tsx`, fullCode) + componentCount++ + } + + // Capture screenshot of the component set + try { + const imageData = await componentSet.exportAsync({ + format: 'PNG', + constraint: { type: 'SCALE', value: 1 }, + }) + componentsFolder?.file(`${componentName}.png`, imageData) + } catch (e) { + console.error(`Failed to capture screenshot for ${componentName}:`, e) + } + } + + // Process each node + for (const node of nodes) { + processedNodes++ + + // 1. Handle COMPONENT_SET directly + if (node.type === 'COMPONENT_SET') { + await processComponentSet(node as ComponentSetNode) + continue + } + + // 2. Handle COMPONENT - check if parent is COMPONENT_SET + if (node.type === 'COMPONENT' && node.parent?.type === 'COMPONENT_SET') { + await processComponentSet(node.parent as ComponentSetNode) + continue + } + + updateProgress(`Processing: ${node.name}`) + + // 3. Extract components using Codegen for other node types + const codegen = new Codegen(node) + await codegen.run() + + const componentsCodes = codegen.getComponentsCodes() + const componentNodes = codegen.getComponentNodes() + + // Add component files + for (const [name, code] of componentsCodes) { + const importStatement = generateImportStatements([[name, code]]) + const fullCode = importStatement + code + componentsFolder?.file(`${name}.tsx`, fullCode) + componentCount++ + } + + // 4. Generate responsive codes for COMPONENT_SET components found inside + for (const componentNode of componentNodes) { + if ( + componentNode.type === 'COMPONENT' && + componentNode.parent?.type === 'COMPONENT_SET' + ) { + await processComponentSet(componentNode.parent as ComponentSetNode) + } + } + + // 5. Check if node is Section or has parent Section for page generation + const isNodeSection = ResponsiveCodegen.canGenerateResponsive(node) + const parentSection = ResponsiveCodegen.hasParentSection(node) + const sectionNode = isNodeSection ? (node as SectionNode) : parentSection + + if (sectionNode) { + const isParentSection = !isNodeSection && parentSection !== null + + updateProgress(`Generating page: ${sectionNode.name}`) + + const responsiveCodegen = new ResponsiveCodegen(sectionNode) + const responsiveCode = await responsiveCodegen.generateResponsiveCode() + const baseName = toPascal(sectionNode.name) + const pageName = isParentSection ? `${baseName}Page` : baseName + const wrappedCode = wrapComponent(pageName, responsiveCode, { + exportDefault: isParentSection, + }) + + const pageCodeEntry: ReadonlyArray = [ + [pageName, wrappedCode], + ] + const importStatement = generateImportStatements(pageCodeEntry) + const fullCode = importStatement + wrappedCode + + pagesFolder?.file(`${pageName}.tsx`, fullCode) + + // Capture screenshot of the section + updateProgress(`Capturing screenshot: ${pageName}`) + try { + const imageData = await sectionNode.exportAsync({ + format: 'PNG', + constraint: { type: 'SCALE', value: 1 }, + }) + pagesFolder?.file(`${pageName}.png`, imageData) + } catch (e) { + console.error(`Failed to capture screenshot for ${pageName}:`, e) + } + + pageCount++ + } + } + + // Check if we have anything to export + if (componentCount === 0 && pageCount === 0) { + notificationHandler.cancel() + figma.notify('No components or pages found') + return + } + + notificationHandler.cancel() + notificationHandler = figma.notify('[100%] Creating zip file...', { + timeout: Infinity, + }) + + await downloadFile( + `${figma.currentPage.name}-export.zip`, + await zip.generateAsync({ type: 'uint8array' }), + ) + + notificationHandler.cancel() + figma.notify( + `Exported ${componentCount} components and ${pageCount} pages`, + { timeout: 3000 }, + ) + } catch (error) { + console.error(error) + notificationHandler.cancel() + figma.notify('Error exporting pages and components', { + timeout: 3000, + error: true, + }) + } +}