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,
+ })
+ }
+}