From 648bd37e3dc8e81559addda6b91e2345ef20b56b Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sat, 13 Dec 2025 12:30:11 +0500 Subject: [PATCH 1/4] feat(openspec): add web platform export proposal Proposal to extend ExFig with native Web/React support: - CSS variables and TypeScript constants for colors - React TSX components via SVGR pattern for icons - Raw SVG/PNG asset export with barrel index files - New WebExport module following FlutterExport patterns --- openspec/changes/add-web-platform/proposal.md | 66 +++++ .../add-web-platform/specs/web-export/spec.md | 226 ++++++++++++++++++ openspec/changes/add-web-platform/tasks.md | 54 +++++ 3 files changed, 346 insertions(+) create mode 100644 openspec/changes/add-web-platform/proposal.md create mode 100644 openspec/changes/add-web-platform/specs/web-export/spec.md create mode 100644 openspec/changes/add-web-platform/tasks.md diff --git a/openspec/changes/add-web-platform/proposal.md b/openspec/changes/add-web-platform/proposal.md new file mode 100644 index 00000000..d3e6b395 --- /dev/null +++ b/openspec/changes/add-web-platform/proposal.md @@ -0,0 +1,66 @@ +# Change: Add Web Platform Export + +## Why + +ExFig currently supports iOS, Android, and Flutter platforms. Web/React projects use a separate toolchain +(`@indriver/figmator` in web-ui) with different patterns and output formats. Adding native Web support to ExFig enables: + +- Unified Figma-to-code pipeline across all platforms +- Consistent asset naming and structure +- Single source of truth for design tokens and icons +- Reduced maintenance burden (one tool instead of two) + +## What Changes + +### New Module: WebExport + +Add `Sources/WebExport/` module following established patterns from FlutterExport: + +- **Colors Export**: CSS variables (`.theme-light { --name: #hex; }`), TypeScript constants (`var(--name)`), JSON tokens +- **Icons Export**: React TSX components via SVGR pattern, raw SVG files, barrel `index.ts` +- **Images Export**: React TSX components, raw PNG/SVG files, barrel `index.ts` + +### Stencil Templates + +Default templates in `Sources/WebExport/Resources/` generate web-ui compatible output: + +- `theme.css.stencil` — CSS with class-based selectors (`.theme-light`, `.theme-dark`) +- `variables.ts.stencil` — TypeScript with `export const variables = {...} as const` +- `Icon.tsx.stencil` — React component with `SVGProps`, `color`, `size`, `style` props +- `types.ts.stencil` — TypeScript interface extending `SVGAttributes` +- `index.ts.stencil` — Barrel exports (`export * from './component-name'`) + +Custom templates can be specified via `web.templatesPath` configuration option. + +### Configuration + +New `web:` section in YAML config with platform-specific output options: + +```yaml +web: + colors: + - tokensFileId: "xxx" + outputDirectory: "src/tokens" + cssFileName: "theme.css" + tsFileName: "variables.ts" + icons: + - figmaFrameName: "Icons" + outputDirectory: "src/icons" + svgDirectory: "assets/icons" + generateReactComponents: true +``` + +### CLI Integration + +Existing commands (`exfig colors`, `exfig icons`, `exfig images`) will automatically process web config when present, +following the same pattern as other platforms. + +## Impact + +- Affected specs: None (new capability) +- Affected code: + - `Package.swift` - new target + - `Sources/ExFigCore/Platform.swift` - new `.web` case + - `Sources/ExFig/Input/Params.swift` - new `Web` struct + - `Sources/ExFig/Subcommands/*.swift` - web export sections +- **BREAKING**: None. Additive change only. diff --git a/openspec/changes/add-web-platform/specs/web-export/spec.md b/openspec/changes/add-web-platform/specs/web-export/spec.md new file mode 100644 index 00000000..7e4d102d --- /dev/null +++ b/openspec/changes/add-web-platform/specs/web-export/spec.md @@ -0,0 +1,226 @@ +## ADDED Requirements + +### Requirement: Web Colors Export + +The system SHALL export Figma color tokens to Web-compatible formats (CSS variables, TypeScript constants, JSON) when +`web.colors` configuration is present. + +#### Scenario: Export colors to CSS variables + +- **GIVEN** a YAML config with `web.colors[].cssFileName: "theme.css"` +- **AND** Figma Variables contain color tokens with light and dark modes +- **WHEN** `exfig colors` is executed +- **THEN** a CSS file is generated with class-based selectors (`.theme-light`, `.theme-dark`) +- **AND** variables use kebab-case naming (e.g., `--background-primary: #ffffff;`) + +**Default CSS output format (web-ui compatible):** + +```css +.theme-light { + --background-primary: #ffffff; + --text-and-icon-primary: #141414; +} + +.theme-dark { + --background-primary: #141414; + --text-and-icon-primary: #ffffff; +} +``` + +#### Scenario: Export colors to TypeScript constants + +- **GIVEN** a YAML config with `web.colors[].tsFileName: "variables.ts"` +- **AND** Figma Variables contain color tokens +- **WHEN** `exfig colors` is executed +- **THEN** a TypeScript file is generated with CSS variable references +- **AND** variable names use kebab-case keys matching CSS variables + +**Default TypeScript output format (web-ui compatible):** + +```typescript +export const variables = { + 'background-primary': 'var(--background-primary)', + 'text-and-icon-primary': 'var(--text-and-icon-primary)', +} as const; +``` + +#### Scenario: Export colors to JSON tokens + +- **GIVEN** a YAML config with `web.colors[].jsonFileName: "theme.json"` +- **AND** Figma Variables contain color tokens with primitives, light, and dark modes +- **WHEN** `exfig colors` is executed +- **THEN** a JSON file is generated with `{ "primitives": {...}, "light": {...}, "dark": {...} }` structure + +#### Scenario: Web colors config not present + +- **GIVEN** a YAML config without `web.colors` section +- **WHEN** `exfig colors` is executed +- **THEN** no web color files are generated +- **AND** other platform exports proceed normally + +### Requirement: Web Icons Export + +The system SHALL export Figma icons to React TSX components and raw SVG files when `web.icons` configuration is present. + +#### Scenario: Export icons as React components + +- **GIVEN** a YAML config with `web.icons[].generateReactComponents: true` +- **AND** Figma frame "Icons" contains SVG components +- **WHEN** `exfig icons` is executed +- **THEN** TSX files are generated with SVGR pattern for each icon +- **AND** each component accepts `size`, `color`, and standard SVG props +- **AND** component names are PascalCase (e.g., `ArrowLeft.tsx`) + +#### Scenario: Export raw SVG files + +- **GIVEN** a YAML config with `web.icons[].svgDirectory: "assets/icons"` +- **AND** Figma frame contains SVG icons +- **WHEN** `exfig icons` is executed +- **THEN** raw SVG files are saved to the specified directory +- **AND** file names are kebab-case (e.g., `arrow-left.svg`) + +#### Scenario: Generate icons index file + +- **GIVEN** a YAML config with `web.icons[].generateIndex: true` +- **AND** multiple icons are exported +- **WHEN** `exfig icons` is executed +- **THEN** an `index.ts` file is generated with re-exports for all icons +- **AND** a `types.ts` file is generated with `SVGProps` interface + +#### Scenario: SVG-to-TSX transformation + +- **GIVEN** an SVG icon with HTML attributes (`class`, `fill-rule`, `stroke-width`) +- **WHEN** the icon is exported as React component +- **THEN** HTML attributes are converted to JSX (`className`, `fillRule`, `strokeWidth`) +- **AND** `width` and `height` are replaced with `{size}` prop +- **AND** static fill colors are replaced with `{color}` prop (default: `currentColor`) + +**Default React component format (web-ui/mireska compatible):** + +```tsx +import React from 'react'; +import SVGProps from './types'; + +const Add = (props: SVGProps): JSX.Element => { + const { color = 'currentColor', size, style } = props; + return ( + + + + ); +}; +export { Add }; +``` + +### Requirement: Web Images Export + +The system SHALL export Figma images/illustrations to React TSX components and raw image files when `web.images` +configuration is present. + +#### Scenario: Export images as React components + +- **GIVEN** a YAML config with `web.images[].generateReactComponents: true` +- **AND** Figma frame "Illustrations" contains image components +- **WHEN** `exfig images` is executed +- **THEN** TSX files are generated for each image +- **AND** components preserve original dimensions from Figma + +#### Scenario: Export raw image files + +- **GIVEN** a YAML config with `web.images[].assetsDirectory: "assets/illustrations"` +- **AND** Figma frame contains PNG or SVG images +- **WHEN** `exfig images` is executed +- **THEN** raw image files are saved to the specified directory +- **AND** file names follow configured naming style + +#### Scenario: Generate images index file + +- **GIVEN** a YAML config with `web.images[].generateIndex: true` +- **AND** multiple images are exported +- **WHEN** `exfig images` is executed +- **THEN** an `index.ts` file is generated with re-exports for all images + +### Requirement: Web Platform Configuration + +The system SHALL support `web:` configuration section in YAML config following the same patterns as `ios:`, `android:`, +and `flutter:` sections. + +#### Scenario: Multiple colors configurations + +- **GIVEN** a YAML config with `web.colors` as an array of entries +- **AND** each entry has different `tokensFileId` or `tokensCollectionName` +- **WHEN** `exfig colors` is executed +- **THEN** all color configurations are processed +- **AND** output files are generated according to each entry's settings + +#### Scenario: Multiple icons configurations + +- **GIVEN** a YAML config with `web.icons` as an array of entries +- **AND** each entry has different `figmaFrameName` +- **WHEN** `exfig icons` is executed +- **THEN** icons from all configured frames are exported +- **AND** each frame's output is placed in its configured directory + +#### Scenario: Web config with custom templates + +- **GIVEN** a YAML config with `web.templatesPath: "./custom-templates"` +- **AND** custom Stencil templates exist at the specified path +- **WHEN** export commands are executed +- **THEN** custom templates are used instead of built-in templates + +### Requirement: React Component Types + +The system SHALL generate TypeScript type definitions for React components that enable type-safe usage. + +#### Scenario: SVGProps type definition + +- **GIVEN** icons are exported with `generateReactComponents: true` +- **WHEN** `types.ts` is generated +- **THEN** it exports `SVGProps` interface extending `SVGAttributes` +- **AND** it includes optional `size?: number | string` property +- **AND** it includes optional `color?: string` property +- **AND** it includes optional `style?: CSSProperties` property + +**Default types.ts format (web-ui compatible):** + +```typescript +import { SVGAttributes, CSSProperties } from 'react'; + +interface SVGProps extends SVGAttributes { + size?: number | string; + color?: string; + style?: CSSProperties; +} + +export default SVGProps; +``` + +#### Scenario: Generate barrel index.ts + +- **GIVEN** icons are exported with `generateIndex: true` +- **AND** multiple icons are generated (e.g., Add.tsx, ArrowLeft.tsx) +- **WHEN** `index.ts` is generated +- **THEN** it exports all icons using `export * from './icon-name'` pattern + +**Default index.ts format (web-ui compatible):** + +```typescript +export * from './add'; +export * from './arrow-left'; +export * from './close'; +``` + +#### Scenario: ColoredSVGProps type definition + +- **GIVEN** colored icons are exported +- **WHEN** `types.ts` is generated +- **THEN** it exports `ColoredSVGProps` interface extending `SVGProps` +- **AND** it includes optional `primaryColor` and `secondaryColor` properties diff --git a/openspec/changes/add-web-platform/tasks.md b/openspec/changes/add-web-platform/tasks.md new file mode 100644 index 00000000..eb8416c1 --- /dev/null +++ b/openspec/changes/add-web-platform/tasks.md @@ -0,0 +1,54 @@ +## 1. Core Infrastructure + +- [ ] 1.1 Add `.web` case to `Sources/ExFigCore/Platform.swift` +- [ ] 1.2 Add WebExport target to `Package.swift` +- [ ] 1.3 Create `Sources/WebExport/WebExporter.swift` base class +- [ ] 1.4 Create `Sources/WebExport/Model/WebOutput.swift` configuration model +- [ ] 1.5 Create `Sources/WebExport/Resources/header.stencil` + +## 2. Colors Export (TDD) + +- [ ] 2.1 Add `Web` struct with `ColorsConfiguration` to `Sources/ExFig/Input/Params.swift` +- [ ] 2.2 Create `Tests/WebExportTests/WebColorExporterTests.swift` +- [ ] 2.3 Create `Sources/WebExport/Resources/theme.css.stencil` +- [ ] 2.4 Create `Sources/WebExport/Resources/variables.ts.stencil` +- [ ] 2.5 Create `Sources/WebExport/Resources/theme.json.stencil` +- [ ] 2.6 Create `Sources/WebExport/WebColorExporter.swift` +- [ ] 2.7 Update `Sources/ExFig/Subcommands/ExportColors.swift` with web export section + +## 3. Icons Export (TDD) + +- [ ] 3.1 Add `IconsConfiguration` to `Web` struct in `Params.swift` +- [ ] 3.2 Create `Tests/WebExportTests/WebIconsExporterTests.swift` +- [ ] 3.3 Create `Sources/WebExport/Resources/Icon.tsx.stencil` +- [ ] 3.4 Create `Sources/WebExport/Resources/types.ts.stencil` +- [ ] 3.5 Create `Sources/WebExport/Resources/IconIndex.ts.stencil` +- [ ] 3.6 Create `Sources/WebExport/WebIconsExporter.swift` with SVG-to-TSX transform +- [ ] 3.7 Update `Sources/ExFig/Subcommands/ExportIcons.swift` with web export section + +## 4. Images Export (TDD) + +- [ ] 4.1 Add `ImagesConfiguration` to `Web` struct in `Params.swift` +- [ ] 4.2 Create `Tests/WebExportTests/WebImagesExporterTests.swift` +- [ ] 4.3 Create `Sources/WebExport/Resources/Image.tsx.stencil` +- [ ] 4.4 Create `Sources/WebExport/Resources/ImageIndex.ts.stencil` +- [ ] 4.5 Create `Sources/WebExport/WebImagesExporter.swift` +- [ ] 4.6 Update `Sources/ExFig/Subcommands/ExportImages.swift` with web export section + +## 5. Documentation + +- [ ] 5.1 Add `web:` config section to `CONFIG.md` +- [ ] 5.2 Add web config template to `Sources/ExFig/Subcommands/GenerateConfigFile.swift` (`exfig init -p web`) +- [ ] 5.3 Create `Sources/ExFig/ExFig.docc/Web.md` article +- [ ] 5.4 Update `README.md` — add Web to platform list and features +- [ ] 5.5 Update `CLAUDE.md` with WebExport module documentation +- [ ] 5.6 Update `.claude/EXFIG.toon` — add WebExport module and templates +- [ ] 5.7 Run `mise run format-md` — format markdown files + +## 6. Final Verification + +- [ ] 6.1 Run `mise run test` — all tests pass +- [ ] 6.2 Run `mise run lint` — no lint errors +- [ ] 6.3 Manual test with web-ui project config +- [ ] 6.4 Verify generated CSS matches web-ui/packages/yrel format +- [ ] 6.5 Verify generated TSX matches web-ui/packages/mireska format From 3555bf97bde2114f051cd1d93a133ade3c6f0e4b Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sat, 13 Dec 2025 14:11:57 +0500 Subject: [PATCH 2/4] feat(web): add web platform export support --- .claude/EXFIG.toon | 17 +- CONFIG.md | 106 +++++++ Package.swift | 16 ++ README.md | 7 +- Sources/ExFig/Input/Params.swift | 188 ++++++++++++ Sources/ExFig/Loaders/IconsLoader.swift | 14 +- Sources/ExFig/Loaders/ImageLoaderBase.swift | 2 +- Sources/ExFig/Loaders/ImagesLoader.swift | 14 + Sources/ExFig/Resources/webConfig.swift | 106 +++++++ Sources/ExFig/Subcommands/ExportColors.swift | 169 +++++++++++ Sources/ExFig/Subcommands/ExportIcons.swift | 269 ++++++++++++++++++ Sources/ExFig/Subcommands/ExportImages.swift | 249 ++++++++++++++++ .../Subcommands/GenerateConfigFile.swift | 2 + Sources/ExFigCore/Platform.swift | 4 + Sources/WebExport/Model/WebOutput.swift | 27 ++ Sources/WebExport/Resources/Icon.tsx.stencil | 16 ++ .../WebExport/Resources/IconIndex.ts.stencil | 5 + Sources/WebExport/Resources/Image.tsx.stencil | 11 + .../WebExport/Resources/ImageIndex.ts.stencil | 5 + Sources/WebExport/Resources/header.stencil | 6 + Sources/WebExport/Resources/theme.css.stencil | 10 + .../WebExport/Resources/theme.json.stencil | 8 + Sources/WebExport/Resources/types.ts.stencil | 12 + .../WebExport/Resources/variables.ts.stencil | 7 + Sources/WebExport/WebColorExporter.swift | 186 ++++++++++++ Sources/WebExport/WebExportError.swift | 19 ++ Sources/WebExport/WebExporter.swift | 34 +++ Sources/WebExport/WebIconsExporter.swift | 193 +++++++++++++ Sources/WebExport/WebImagesExporter.swift | 175 ++++++++++++ .../WebColorExporterTests.swift | 172 +++++++++++ .../WebIconsExporterTests.swift | 124 ++++++++ .../WebImagesExporterTests.swift | 108 +++++++ Tests/WebExportTests/header.swift | 8 + openspec/changes/add-web-platform/tasks.md | 74 ++--- 34 files changed, 2314 insertions(+), 49 deletions(-) create mode 100644 Sources/ExFig/Resources/webConfig.swift create mode 100644 Sources/WebExport/Model/WebOutput.swift create mode 100644 Sources/WebExport/Resources/Icon.tsx.stencil create mode 100644 Sources/WebExport/Resources/IconIndex.ts.stencil create mode 100644 Sources/WebExport/Resources/Image.tsx.stencil create mode 100644 Sources/WebExport/Resources/ImageIndex.ts.stencil create mode 100644 Sources/WebExport/Resources/header.stencil create mode 100644 Sources/WebExport/Resources/theme.css.stencil create mode 100644 Sources/WebExport/Resources/theme.json.stencil create mode 100644 Sources/WebExport/Resources/types.ts.stencil create mode 100644 Sources/WebExport/Resources/variables.ts.stencil create mode 100644 Sources/WebExport/WebColorExporter.swift create mode 100644 Sources/WebExport/WebExportError.swift create mode 100644 Sources/WebExport/WebExporter.swift create mode 100644 Sources/WebExport/WebIconsExporter.swift create mode 100644 Sources/WebExport/WebImagesExporter.swift create mode 100644 Tests/WebExportTests/WebColorExporterTests.swift create mode 100644 Tests/WebExportTests/WebIconsExporterTests.swift create mode 100644 Tests/WebExportTests/WebImagesExporterTests.swift create mode 100644 Tests/WebExportTests/header.swift diff --git a/.claude/EXFIG.toon b/.claude/EXFIG.toon index 8de3530a..166ff5fd 100644 --- a/.claude/EXFIG.toon +++ b/.claude/EXFIG.toon @@ -1,7 +1,7 @@ meta: name: ExFig version: current - description: CLI tool for exporting Figma assets to iOS, Android, Flutter + description: CLI tool for exporting Figma assets to iOS, Android, Flutter, Web swift: 6.0 platforms: macOS 12.0+, Linux license: MIT @@ -54,7 +54,7 @@ commands: init: purpose: Generate starter config file options: - platform: -p/--platform ios|android (required) + platform: -p/--platform ios|android|flutter|web (required) batch: purpose: Process multiple configs in parallel @@ -107,13 +107,14 @@ commands: failFast: --fail-fast resume: --resume -modules[7]{name,path,purpose}: +modules[8]{name,path,purpose}: ExFig,Sources/ExFig,CLI commands and orchestration ExFigCore,Sources/ExFigCore,Domain models and processors FigmaAPI,Sources/FigmaAPI,HTTP client with rate limiting XcodeExport,Sources/XcodeExport,iOS xcassets and Swift extensions AndroidExport,Sources/AndroidExport,Android XML and Compose code FlutterExport,Sources/FlutterExport,Flutter Dart code + WebExport,Sources/WebExport,Web CSS variables and React/TypeScript SVGKit,Sources/SVGKit,SVG parsing and vector generation keyDirectories: @@ -132,7 +133,7 @@ configTypes: IconsConfiguration: purpose: Enum for backward-compatible icons config parsing cases: [single(Icons), multiple([IconsEntry])] - platforms: [iOS, Android, Flutter] + platforms: [iOS, Android, Flutter, Web] properties: entries: "[IconsEntry] - unified access to all entries" isMultiple: "Bool - true if array format" @@ -145,7 +146,7 @@ configTypes: IconsLoaderConfig: purpose: Sendable struct for IconsLoader frame settings file: Sources/ExFig/Loaders/IconsLoader.swift - factoryMethods: [forIOS, forAndroid, forFlutter, defaultConfig] + factoryMethods: [forIOS, forAndroid, forFlutter, forWeb, defaultConfig] keyFiles: cli: Sources/ExFig/ExFigCommand.swift @@ -237,11 +238,12 @@ templates: ios[10]: UIColor+extension, Color+extension, UIFont+extension, Font+extension, UIImage+extension, Image+extension, Label, LabelStyle, LabelStyle+extension, header android[6]: colors.xml, Colors.kt, typography.xml, Typography.kt, Icons.kt, header flutter[4]: colors.dart, icons.dart, images.dart, header + web[8]: theme.css, variables.ts, theme.json, Icon.tsx, types.ts, IconIndex.ts, Image.tsx, ImageIndex.ts, header config: files: exfig.yaml, figma-export.yaml envVar: FIGMA_PERSONAL_TOKEN - sections: figma, common, ios, android, flutter + sections: figma, common, ios, android, flutter, web reference: CONFIG.md buildCommands: @@ -281,11 +283,12 @@ figmaAPI: tier3: 50-150 req/min docs: https://www.figma.com/developers/api -testTargets[7]{name,tests}: +testTargets[8]{name,tests}: ExFigTests,CLI commands and loaders ExFigCoreTests,Domain models and processors XcodeExportTests,iOS export output AndroidExportTests,Android export output FlutterExportTests,Flutter export output + WebExportTests,Web CSS and React/TypeScript output FigmaAPITests,API client and endpoints SVGKitTests,SVG parsing and code generation diff --git a/CONFIG.md b/CONFIG.md index 150744ca..b75290d5 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -325,6 +325,64 @@ flutter: encoding: lossy # Encoding quality in percents. Only for lossy encoding. quality: 90 + +# [optional] Web export parameters (React/TypeScript) +web: + # Relative or absolute path to the output directory for generated files + output: "./src/generated" + # [optional] Path to the Stencil templates used to generate code + templatesPath: "./templates" + + # Parameters for exporting colors + colors: + # [optional] Output file name for CSS variables. Defaults to "theme.css" + cssFile: "theme.css" + # [optional] Output file name for TypeScript constants. Defaults to "variables.ts" + tsFile: "variables.ts" + # [optional] Output file name for JSON tokens. When specified, exports colors as JSON + jsonFile: "tokens.json" + + # Parameters for exporting icons + # Can be a single object (legacy format) or an array of objects (new format) + # Legacy format (single icons configuration): + icons: + # [optional] Where to place SVG icon assets (relative path) + assetsDirectory: "assets/icons" + # [optional] Generate React TSX components for each icon. Defaults to true + generateReactComponents: true + # [optional] Export types.ts with TypeScript interfaces. Defaults to true + exportTypes: true + # [optional] Icon size in pixels for viewBox. Defaults to 24 + iconSize: 24 + + # New format (multiple icons configurations from different Figma frames): + # icons: + # - figmaFrameName: Actions # Export icons from "Actions" frame + # assetsDirectory: "assets/icons/actions" + # generateReactComponents: true + # iconSize: 24 # Icon size for viewBox + # - figmaFrameName: Navigation # Export icons from "Navigation" frame + # assetsDirectory: "assets/icons/nav" + # generateReactComponents: true + # iconSize: 20 # Different icon size for navigation + + # Parameters for exporting images + # Can be a single object (legacy format) or an array of objects (new format) + # Legacy format (single images configuration): + images: + # [optional] Where to place image assets (relative path) + assetsDirectory: "assets/images" + # [optional] Generate React TSX components for each image. Defaults to true + generateReactComponents: true + + # New format (multiple images configurations from different Figma frames): + # images: + # - figmaFrameName: Illustrations + # assetsDirectory: "assets/images/illustrations" + # generateReactComponents: true + # - figmaFrameName: Promo + # assetsDirectory: "assets/images/promo" + # generateReactComponents: true ``` ## Multiple Icons Configuration @@ -392,6 +450,22 @@ When using multiple entries with the same Figma file: This means 17 icon entries with the same `lightFileId` result in only 1 Components API call (plus 1 Images API call per unique frame), not 17 separate calls. +### Web Icons Array Format + +```yaml +web: + icons: + - figmaFrameName: Actions + assetsDirectory: assets/icons/actions + generateReactComponents: true + iconSize: 24 + - figmaFrameName: Navigation + assetsDirectory: assets/icons/nav + generateReactComponents: true + exportTypes: true + iconSize: 20 +``` + ## Multiple Colors Configuration ExFig supports exporting colors from multiple Figma Variable collections in a single config file. This is useful when @@ -493,6 +567,25 @@ flutter: className: ThemeColors ``` +### Web Colors Array Format + +```yaml +web: + colors: + - tokensFileId: abc123 + tokensCollectionName: Base Palette + lightModeName: Light + cssFile: base-theme.css + tsFile: base-variables.ts + - tokensFileId: def456 + tokensCollectionName: Theme Colors + lightModeName: Light + darkModeName: Dark + cssFile: theme.css + tsFile: theme-variables.ts + jsonFile: theme-tokens.json +``` + ## Multiple Images Configuration ExFig supports exporting images from multiple Figma frames in a single config file. This is useful when your design @@ -575,6 +668,19 @@ flutter: scales: [1, 2, 3] ``` +### Web Images Array Format + +```yaml +web: + images: + - figmaFrameName: Illustrations + assetsDirectory: assets/images/illustrations + generateReactComponents: true + - figmaFrameName: Promo + assetsDirectory: assets/images/promo + generateReactComponents: true +``` + ### Fallback Behavior If `figmaFrameName` is not specified in an entry, it falls back to: diff --git a/Package.swift b/Package.swift index a25e51ca..ca2ddde7 100644 --- a/Package.swift +++ b/Package.swift @@ -35,6 +35,7 @@ let package = Package( "XcodeExport", "AndroidExport", "FlutterExport", + "WebExport", "SVGKit", .product(name: "XcodeProj", package: "XcodeProj"), .product(name: "ArgumentParser", package: "swift-argument-parser"), @@ -95,6 +96,15 @@ let package = Package( ] ), + // Exports resources to Web/React project + .target( + name: "WebExport", + dependencies: ["ExFigCore", "Stencil", "StencilSwiftKit"], + resources: [ + .copy("Resources/"), + ] + ), + // MARK: - Tests .testTarget( @@ -142,6 +152,12 @@ let package = Package( "FlutterExport", .product(name: "CustomDump", package: "swift-custom-dump"), ] ), + .testTarget( + name: "WebExportTests", + dependencies: [ + "WebExport", .product(name: "CustomDump", package: "swift-custom-dump"), + ] + ), .testTarget( name: "SVGKitTests", dependencies: ["SVGKit", .product(name: "CustomDump", package: "swift-custom-dump")] diff --git a/README.md b/README.md index a403a042..07cf2176 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@ ![Coverage](https://img.shields.io/badge/coverage-51.30%25-yellow) [![License](https://img.shields.io/github/license/alexey1312/ExFig.svg)](LICENSE) -Command-line utility to export colors, typography, icons, and images from Figma to Xcode, Android Studio, and Flutter -projects. +Command-line utility to export colors, typography, icons, and images from Figma to Xcode, Android Studio, Flutter, and +Web (React/TypeScript) projects. Automatically sync your design system from Figma to code with support for Dark Mode, SwiftUI, UIKit, Jetpack Compose, -and Flutter. +Flutter, and React/TypeScript. ## Why ExFig? @@ -38,6 +38,7 @@ and Flutter. - 📱 SwiftUI and UIKit (iOS/macOS) - 🤖 Jetpack Compose and XML resources (Android) - 🦋 Flutter / Dart +- 🌐 React / TypeScript (CSS variables, TSX components) - 🔧 Customizable code templates (Stencil) ### Export Formats diff --git a/Sources/ExFig/Input/Params.swift b/Sources/ExFig/Input/Params.swift index 4774b817..96cb7181 100644 --- a/Sources/ExFig/Input/Params.swift +++ b/Sources/ExFig/Input/Params.swift @@ -731,9 +731,197 @@ struct Params: Decodable { let templatesPath: URL? } + // MARK: - Web + + struct Web: Decodable { + /// Single colors configuration (legacy format). + /// Uses common.variablesColors for Figma Variables source. + struct Colors: Decodable { + let outputDirectory: String? + let cssFileName: String? + let tsFileName: String? + let jsonFileName: String? + } + + /// Colors entry with Figma Variables source for multiple colors configuration. + struct ColorsEntry: Decodable { + // Source (Figma Variables) + let tokensFileId: String + let tokensCollectionName: String + let lightModeName: String + let darkModeName: String? + let lightHCModeName: String? + let darkHCModeName: String? + let primitivesModeName: String? + let nameValidateRegexp: String? + let nameReplaceRegexp: String? + + // Output (Web-specific) + let outputDirectory: String? + let cssFileName: String? + let tsFileName: String? + let jsonFileName: String? + } + + /// Colors configuration supporting both single object and array formats. + enum ColorsConfiguration: Decodable { + case single(Colors) + case multiple([ColorsEntry]) + + init(from decoder: Decoder) throws { + if let array = try? [ColorsEntry](from: decoder) { + self = .multiple(array) + return + } + let single = try Colors(from: decoder) + self = .single(single) + } + + var entries: [ColorsEntry] { + switch self { + case let .single(colors): + [ColorsEntry( + tokensFileId: "", + tokensCollectionName: "", + lightModeName: "", + darkModeName: nil, + lightHCModeName: nil, + darkHCModeName: nil, + primitivesModeName: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil, + outputDirectory: colors.outputDirectory, + cssFileName: colors.cssFileName, + tsFileName: colors.tsFileName, + jsonFileName: colors.jsonFileName + )] + case let .multiple(entries): + entries + } + } + + var isMultiple: Bool { + if case .multiple = self { return true } + return false + } + } + + /// Single icons configuration (legacy format). + struct Icons: Decodable { + let outputDirectory: String + let svgDirectory: String? + let generateReactComponents: Bool? + /// Icon size in pixels for viewBox. Defaults to 24. + let iconSize: Int? + } + + /// Icons entry with figmaFrameName for multiple icons configuration. + struct IconsEntry: Decodable { + /// Figma frame name to export icons from. Overrides common.icons.figmaFrameName. + let figmaFrameName: String? + let outputDirectory: String + let svgDirectory: String? + let generateReactComponents: Bool? + /// Icon size in pixels for viewBox. Defaults to 24. + let iconSize: Int? + } + + /// Icons configuration supporting both single object and array formats. + enum IconsConfiguration: Decodable { + case single(Icons) + case multiple([IconsEntry]) + + init(from decoder: Decoder) throws { + if let array = try? [IconsEntry](from: decoder) { + self = .multiple(array) + return + } + let single = try Icons(from: decoder) + self = .single(single) + } + + var entries: [IconsEntry] { + switch self { + case let .single(icons): + [IconsEntry( + figmaFrameName: nil, + outputDirectory: icons.outputDirectory, + svgDirectory: icons.svgDirectory, + generateReactComponents: icons.generateReactComponents, + iconSize: icons.iconSize + )] + case let .multiple(entries): + entries + } + } + + var isMultiple: Bool { + if case .multiple = self { return true } + return false + } + } + + /// Single images configuration (legacy format). + struct Images: Decodable { + let outputDirectory: String + let assetsDirectory: String? + let generateReactComponents: Bool? + } + + /// Images entry with figmaFrameName for multiple images configuration. + struct ImagesEntry: Decodable { + /// Figma frame name to export images from. Overrides common.images.figmaFrameName. + let figmaFrameName: String? + let outputDirectory: String + let assetsDirectory: String? + let generateReactComponents: Bool? + } + + /// Images configuration supporting both single object and array formats. + enum ImagesConfiguration: Decodable { + case single(Images) + case multiple([ImagesEntry]) + + init(from decoder: Decoder) throws { + if let array = try? [ImagesEntry](from: decoder) { + self = .multiple(array) + return + } + let single = try Images(from: decoder) + self = .single(single) + } + + var entries: [ImagesEntry] { + switch self { + case let .single(images): + [ImagesEntry( + figmaFrameName: nil, + outputDirectory: images.outputDirectory, + assetsDirectory: images.assetsDirectory, + generateReactComponents: images.generateReactComponents + )] + case let .multiple(entries): + entries + } + } + + var isMultiple: Bool { + if case .multiple = self { return true } + return false + } + } + + let output: URL + let colors: ColorsConfiguration? + let icons: IconsConfiguration? + let images: ImagesConfiguration? + let templatesPath: URL? + } + let figma: Figma let common: Common? let ios: iOS? let android: Android? let flutter: Flutter? + let web: Web? } diff --git a/Sources/ExFig/Loaders/IconsLoader.swift b/Sources/ExFig/Loaders/IconsLoader.swift index 59fd50f5..272df292 100644 --- a/Sources/ExFig/Loaders/IconsLoader.swift +++ b/Sources/ExFig/Loaders/IconsLoader.swift @@ -68,6 +68,18 @@ struct IconsLoaderConfig: Sendable { ) } + /// Creates config for Web (no iOS-specific fields needed). + static func forWeb(entry: Params.Web.IconsEntry, params: Params) -> IconsLoaderConfig { + IconsLoaderConfig( + frameName: entry.figmaFrameName ?? params.common?.icons?.figmaFrameName ?? "Icons", + format: nil, + renderMode: nil, + renderModeDefaultSuffix: nil, + renderModeOriginalSuffix: nil, + renderModeTemplateSuffix: nil + ) + } + /// Creates default config using common.icons.figmaFrameName or "Icons". static func defaultConfig(params: Params) -> IconsLoaderConfig { IconsLoaderConfig( @@ -207,7 +219,7 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { private func makeFormatParams() -> FormatParams { switch (platform, config.format) { - case (.android, _), (.flutter, _), (.ios, .svg): + case (.android, _), (.flutter, _), (.web, _), (.ios, .svg): SVGParams() case (.ios, _): PDFParams() diff --git a/Sources/ExFig/Loaders/ImageLoaderBase.swift b/Sources/ExFig/Loaders/ImageLoaderBase.swift index 605dc03e..e77a096d 100644 --- a/Sources/ExFig/Loaders/ImageLoaderBase.swift +++ b/Sources/ExFig/Loaders/ImageLoaderBase.swift @@ -840,7 +840,7 @@ extension String { func parseNameAndIdiom(platform: Platform) -> (name: String, idiom: String) { switch platform { - case .android, .flutter: + case .android, .flutter, .web: return (self, "") case .ios: guard let regex = Self.idiomRegex, diff --git a/Sources/ExFig/Loaders/ImagesLoader.swift b/Sources/ExFig/Loaders/ImagesLoader.swift index 443ab73e..cf6b7d86 100644 --- a/Sources/ExFig/Loaders/ImagesLoader.swift +++ b/Sources/ExFig/Loaders/ImagesLoader.swift @@ -49,6 +49,15 @@ struct ImagesLoaderConfig: Sendable { ) } + /// Creates config for a specific Web images entry. + static func forWeb(entry: Params.Web.ImagesEntry, params: Params) -> ImagesLoaderConfig { + ImagesLoaderConfig( + frameName: entry.figmaFrameName ?? params.common?.images?.figmaFrameName ?? "Illustrations", + scales: nil, + format: .svg // Web uses SVG by default + ) + } + /// Creates default config from params (for backward compatibility). static func defaultConfig(params: Params) -> ImagesLoaderConfig { ImagesLoaderConfig( @@ -132,6 +141,11 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di case (.android, nil), (.flutter, nil): // Default to raster for backward compatibility true + case (.web, .svg), (.web, nil): + // Web uses SVG by default + false + case (.web, .png), (.web, .webp): + true } } diff --git a/Sources/ExFig/Resources/webConfig.swift b/Sources/ExFig/Resources/webConfig.swift new file mode 100644 index 00000000..e8071210 --- /dev/null +++ b/Sources/ExFig/Resources/webConfig.swift @@ -0,0 +1,106 @@ +let webConfigFileContents = #""" +--- +figma: + # Identifier of the file containing light color palette, icons and light images. + # To obtain a file id, open the file in the browser. + # The file id will be present in the URL after the word file and before the file name. + lightFileId: shPilWnVdJfo10YF12345 + # [optional] Identifier of the file containing dark color palette and dark images. + darkFileId: KfF6DnJTWHGZzC912345 + # [optional] Figma API request timeout. The default value of this property is 30 (seconds). + # If you have a lot of resources to export set this value to 60 or more. + # timeout: 30 + +# [optional] Common export parameters +common: + # [optional] + colors: + # [optional] RegExp pattern for color name validation before exporting. + # If a name contains "/" symbol it will be replaced by "_" before executing the RegExp. + nameValidateRegexp: '^([a-zA-Z_]+)$' + # [optional] RegExp pattern for replacing. Supports only $n + nameReplaceRegexp: 'color_$1' + # [optional] Extract light and dark mode colors from the lightFileId. Defaults to false + useSingleFile: false + # [optional] If useSingleFile is true, customize the suffix for dark mode. Defaults to '_dark' + darkModeSuffix: '_dark' + # [optional] Use variablesColors to export colors from Figma Variables. + # variablesColors: + # # [required] Identifier of the file containing variables + # tokensFileId: shPilWnVdJfo10YF12345 + # # [required] Variables collection name + # tokensCollectionName: Base collection + # # [required] Name of the column containing light color variables in the tokens table + # lightModeName: Light + # # [optional] Name of the column containing dark color variables in the tokens table + # darkModeName: Dark + # # [optional] Name of the column containing color variables in the primitive table. + # primitivesModeName: Collection_1 + # # [optional] RegExp pattern for color name validation before exporting. + # nameValidateRegexp: '^([a-zA-Z_]+)$' + # # [optional] RegExp pattern for replacing. Supports only $n + # nameReplaceRegexp: 'color_$1' + # [optional] + icons: + # [optional] Name of the Figma's frame where icons components are located + figmaFrameName: Icons + # [optional] RegExp pattern for icon name validation before exporting. + # If a name contains "/" symbol it will be replaced by "_" before executing the RegExp. + nameValidateRegexp: '^(ic)_(\d\d)_([a-z0-9_]+)$' + # [optional] RegExp pattern for replacing. Supports only $n + nameReplaceRegexp: 'icon_$2_$1' + # [optional] Extract light and dark mode icons from the lightFileId. Defaults to false + useSingleFile: false + # [optional] If useSingleFile is true, customize the suffix for dark mode. Defaults to '_dark' + darkModeSuffix: '_dark' + # [optional] + images: + # [optional] Name of the Figma's frame where image components are located + figmaFrameName: Illustrations + # [optional] RegExp pattern for image name validation before exporting. + # If a name contains "/" symbol it will be replaced by "_" before executing the RegExp. + nameValidateRegexp: '^(img)_([a-z0-9_]+)$' + # [optional] RegExp pattern for replacing. Supports only $n + nameReplaceRegexp: 'image_$2' + # [optional] Extract light and dark mode images from the lightFileId. Defaults to false + useSingleFile: false + # [optional] If useSingleFile is true, customize the suffix for dark mode. Defaults to '_dark' + darkModeSuffix: '_dark' + +# Web/React export parameters +web: + # Output directory for generated TypeScript/CSS files + output: "./src/tokens" + # [optional] Path to the Stencil templates used to generate code + # templatesPath: "./Resources/Templates" + + # Parameters for exporting colors + colors: + # [optional] Output directory for color files (overrides web.output) + outputDirectory: "." + # [optional] CSS file name for theme variables + cssFileName: "theme.css" + # [optional] TypeScript file name for CSS variable references + tsFileName: "variables.ts" + # [optional] JSON file name for design tokens (useful for tooling integration) + # jsonFileName: "tokens.json" + + # Parameters for exporting icons + icons: + # Output directory for React icon components + outputDirectory: "./src/icons" + # [optional] Directory for raw SVG files + svgDirectory: "assets/icons" + # [optional] Generate React TSX components (default: true) + generateReactComponents: true + + # Parameters for exporting images + images: + # Output directory for image components + outputDirectory: "./src/images" + # [optional] Directory for raw image assets + assetsDirectory: "assets/images" + # [optional] Generate React TSX components (default: true) + generateReactComponents: true + +"""# diff --git a/Sources/ExFig/Subcommands/ExportColors.swift b/Sources/ExFig/Subcommands/ExportColors.swift index ed5a1c53..d3cecda1 100644 --- a/Sources/ExFig/Subcommands/ExportColors.swift +++ b/Sources/ExFig/Subcommands/ExportColors.swift @@ -4,6 +4,7 @@ import ExFigCore import FigmaAPI import FlutterExport import Foundation +import WebExport import XcodeExport // swiftlint:disable file_length type_body_length @@ -154,6 +155,30 @@ extension ExFigCommand { } } + // Web export + if let web = options.params.web, let colorsConfig = web.colors { + if colorsConfig.isMultiple { + totalCount += try await exportWebColorsMultiple( + entries: colorsConfig.entries, + web: web, + client: client, + ui: ui + ) + } else { + let config = LegacyExportConfig( + commonParams: commonParams, + figmaParams: figmaParams, + client: client, + ui: ui + ) + totalCount += try await exportWebColorsLegacy( + colorsConfig: colorsConfig, + web: web, + config: config + ) + } + } + // Update cache after successful export try VersionTrackingHelper.updateCacheIfNeeded( manager: trackingManager, versions: fileVersions @@ -653,5 +678,149 @@ extension ExFigCommand { try fileWriter.write(files: files) } + + // MARK: - Web Colors Export + + private func exportWebColorsMultiple( + entries: [Params.Web.ColorsEntry], + web: Params.Web, + client: Client, + ui: TerminalUI + ) async throws -> Int { + var totalCount = 0 + + for entry in entries { + let colors = try await ui.withSpinner( + "Fetching colors from Figma (\(entry.tokensCollectionName))..." + ) { + let loader = ColorsVariablesLoader( + client: client, + figmaParams: options.params.figma, + variableParams: Params.Common.VariablesColors( + tokensFileId: entry.tokensFileId, + tokensCollectionName: entry.tokensCollectionName, + lightModeName: entry.lightModeName, + darkModeName: entry.darkModeName, + lightHCModeName: entry.lightHCModeName, + darkHCModeName: entry.darkHCModeName, + primitivesModeName: entry.primitivesModeName, + nameValidateRegexp: entry.nameValidateRegexp, + nameReplaceRegexp: entry.nameReplaceRegexp + ), + filter: filter + ) + return try await loader.load() + } + + let colorPairs = try await ui.withSpinner("Processing colors for Web...") { + let processor = ColorsProcessor( + platform: .web, + nameValidateRegexp: entry.nameValidateRegexp, + nameReplaceRegexp: entry.nameReplaceRegexp, + nameStyle: .kebabCase + ) + let result = processor.process(light: colors.light, dark: colors.dark) + if let warning = result.warning { + ui.warning(warning) + } + return try result.get() + } + + try await ui.withSpinner("Exporting colors to Web project...") { + try exportWebColorsEntry(colorPairs: colorPairs, entry: entry, web: web) + } + + totalCount += colorPairs.count + } + + if BatchProgressViewStorage.progressView == nil { + await checkForUpdate(logger: logger) + } + + ui.success("Done! Exported \(totalCount) colors to Web project.") + return totalCount + } + + private func exportWebColorsLegacy( + colorsConfig: Params.Web.ColorsConfiguration, + web: Params.Web, + config: LegacyExportConfig + ) async throws -> Int { + try validateLegacyConfig(config.commonParams) + + let colors = try await loadLegacyColors(config: config) + + let (finalNameValidateRegexp, finalNameReplaceRegexp) = extractNameRegexps( + from: config.commonParams + ) + + let entry = colorsConfig.entries[0] + + let colorPairs = try await config.ui.withSpinner("Processing colors for Web...") { + let processor = ColorsProcessor( + platform: .web, + nameValidateRegexp: finalNameValidateRegexp, + nameReplaceRegexp: finalNameReplaceRegexp, + nameStyle: .kebabCase + ) + let result = processor.process(light: colors.light, dark: colors.dark) + if let warning = result.warning { + config.ui.warning(warning) + } + return try result.get() + } + + try await config.ui.withSpinner("Exporting colors to Web project...") { + try exportWebColorsEntry(colorPairs: colorPairs, entry: entry, web: web) + } + + if BatchProgressViewStorage.progressView == nil { + await checkForUpdate(logger: logger) + } + + config.ui.success("Done! Exported \(colorPairs.count) colors to Web project.") + return colorPairs.count + } + + private func exportWebColorsEntry( + colorPairs: [AssetPair], + entry: Params.Web.ColorsEntry, + web: Params.Web + ) throws { + let outputDir = if let dir = entry.outputDirectory { + web.output.appendingPathComponent(dir) + } else { + web.output + } + + let output = WebOutput( + outputDirectory: outputDir, + templatesPath: web.templatesPath + ) + let exporter = WebColorExporter( + output: output, + cssFileName: entry.cssFileName, + tsFileName: entry.tsFileName, + jsonFileName: entry.jsonFileName + ) + let files = try exporter.export(colorPairs: colorPairs) + + // Remove existing files + let cssFileName = entry.cssFileName ?? "theme.css" + let tsFileName = entry.tsFileName ?? "variables.ts" + + let cssFileURL = outputDir.appendingPathComponent(cssFileName) + let tsFileURL = outputDir.appendingPathComponent(tsFileName) + + try? FileManager.default.removeItem(atPath: cssFileURL.path) + try? FileManager.default.removeItem(atPath: tsFileURL.path) + + if let jsonFileName = entry.jsonFileName { + let jsonFileURL = outputDir.appendingPathComponent(jsonFileName) + try? FileManager.default.removeItem(atPath: jsonFileURL.path) + } + + try fileWriter.write(files: files) + } } } diff --git a/Sources/ExFig/Subcommands/ExportIcons.swift b/Sources/ExFig/Subcommands/ExportIcons.swift index 2412318e..30efaec1 100644 --- a/Sources/ExFig/Subcommands/ExportIcons.swift +++ b/Sources/ExFig/Subcommands/ExportIcons.swift @@ -6,6 +6,7 @@ import FigmaAPI import FlutterExport import Foundation import SVGKit +import WebExport import XcodeExport extension ExFigCommand { @@ -177,6 +178,22 @@ extension ExFigCommand { mergeHashes(&allComputedHashes, result.hashes) } + if options.params.web != nil { + // Suppress version message in batch mode + if BatchProgressViewStorage.progressView == nil { + ui.info("Using ExFig \(ExFigCommand.version) to export icons to Web project.") + } + let result = try await exportWebIcons( + client: client, + params: options.params, + ui: ui, + granularCacheManager: granularCacheManager + ) + totalIcons += result.count + totalSkipped += result.skippedCount + mergeHashes(&allComputedHashes, result.hashes) + } + // Update file version cache after successful export try VersionTrackingHelper.updateCacheIfNeeded(manager: trackingManager, versions: fileVersions) @@ -1188,5 +1205,257 @@ extension ExFigCommand { skippedCount: skippedCount ) } + + // MARK: - Web Icons Export + + private func exportWebIcons( + client: Client, + params: Params, + ui: TerminalUI, + granularCacheManager: GranularCacheManager? + ) async throws -> PlatformExportResult { + guard let web = params.web, let iconsConfig = web.icons else { + ui.warning(.configMissing(platform: "web", assetType: "icons")) + return PlatformExportResult(count: 0, hashes: [:], skippedCount: 0) + } + + // Get all entries from config (supports both single and multiple formats) + let entries = iconsConfig.entries + + // Single entry - use direct processing (legacy behavior) + if entries.count == 1 { + return try await exportWebIconsEntry( + entry: entries[0], + web: web, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + } + + // Multiple entries - need to pre-fetch components if not already done + let needsLocalPreFetch = PreFetchedComponentsStorage.components == nil + + if needsLocalPreFetch { + var componentsMap: [String: [Component]] = [:] + let fileIds = Set([params.figma.lightFileId] + (params.figma.darkFileId.map { [$0] } ?? [])) + + for fileId in fileIds { + let components = try await client.request(ComponentsEndpoint(fileId: fileId)) + componentsMap[fileId] = components + } + + let preFetched = PreFetchedComponents(components: componentsMap) + + // Inject via TaskLocal - IconsLoader will use pre-fetched components + return try await PreFetchedComponentsStorage.$components.withValue(preFetched) { + try await processWebIconsEntries( + entries: entries, + web: web, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + } + } else { + // Already have pre-fetched components (batch mode) + return try await processWebIconsEntries( + entries: entries, + web: web, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + } + } + + // Helper to process multiple Web icon entries sequentially. + // swiftlint:disable:next function_parameter_count + private func processWebIconsEntries( + entries: [Params.Web.IconsEntry], + web: Params.Web, + client: Client, + params: Params, + ui: TerminalUI, + granularCacheManager: GranularCacheManager? + ) async throws -> PlatformExportResult { + var totalCount = 0 + var totalSkipped = 0 + var allHashes: [String: [NodeId: String]] = [:] + + for entry in entries { + let result = try await exportWebIconsEntry( + entry: entry, + web: web, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + totalCount += result.count + totalSkipped += result.skippedCount + mergeHashes(&allHashes, result.hashes) + } + + return PlatformExportResult( + count: totalCount, + hashes: allHashes, + skippedCount: totalSkipped + ) + } + + // Exports icons for a single Web icons entry. + // swiftlint:disable:next function_body_length function_parameter_count + private func exportWebIconsEntry( + entry: Params.Web.IconsEntry, + web: Params.Web, + client: Client, + params: Params, + ui: TerminalUI, + granularCacheManager: GranularCacheManager? + ) async throws -> PlatformExportResult { + let loaderConfig = IconsLoaderConfig.forWeb(entry: entry, params: params) + + // 1. Get Icons info + let loaderResult = try await ui.withSpinnerProgress("Fetching icons from Figma...") { onProgress in + let loader = IconsLoader( + client: client, + params: params, + platform: .web, + logger: logger, + config: loaderConfig + ) + if let manager = granularCacheManager { + loader.granularCacheManager = manager + return try await loader.loadWithGranularCache(filter: filter, onBatchProgress: onProgress) + } else { + let output = try await loader.load(filter: filter, onBatchProgress: onProgress) + return IconsLoaderResultWithHashes( + light: output.light, + dark: output.dark, + computedHashes: [:], + allSkipped: false, + allNames: [] // Not needed when not using granular cache + ) + } + } + + // If granular cache skipped all icons, return early + if loaderResult.allSkipped { + ui.success("All icons unchanged (granular cache). Skipping export.") + return PlatformExportResult( + count: 0, + hashes: loaderResult.computedHashes, + skippedCount: loaderResult.allNames.count + ) + } + + let imagesTuple = (light: loaderResult.light, dark: loaderResult.dark) + + // 2. Process images + let processor = ImagesProcessor( + platform: .web, + nameValidateRegexp: params.common?.icons?.nameValidateRegexp, + nameReplaceRegexp: params.common?.icons?.nameReplaceRegexp, + nameStyle: .snakeCase + ) + + let (icons, iconsWarning): ([AssetPair], AssetsValidatorWarning?) = + try await ui.withSpinner("Processing icons...") { + let result = processor.process(light: imagesTuple.light, dark: imagesTuple.dark) + return try (result.get(), result.warning) + } + if let iconsWarning { + ui.warning(iconsWarning) + } + + if icons.isEmpty, loaderResult.computedHashes.isEmpty { + ui.warning(.noAssetsFound( + assetType: "icons", + frameName: loaderConfig.frameName + )) + return PlatformExportResult(count: 0, hashes: [:], skippedCount: 0) + } + + // 3. Get download URLs and generate export result + let svgDir = entry.svgDirectory.map { web.output.appendingPathComponent($0) } + ?? web.output.appendingPathComponent("assets/icons") + let outputDir = web.output.appendingPathComponent(entry.outputDirectory) + + let output = WebOutput( + outputDirectory: outputDir, + iconsAssetsDirectory: svgDir, + templatesPath: web.templatesPath + ) + let generateReactComponents = entry.generateReactComponents ?? true + let iconSize = entry.iconSize ?? 24 + let exporter = WebIconsExporter( + output: output, + generateReactComponents: generateReactComponents, + iconSize: iconSize + ) + + // Use allNames for barrel file if granular cache is active + let allIconNames = granularCacheManager != nil ? loaderResult.allNames : nil + let result = try exporter.export(icons: icons, allIconNames: allIconNames) + + // 4. Collect all files to write + var localFiles: [FileContents] = result.assetFiles + localFiles.append(contentsOf: result.componentFiles) + if let typesFile = result.typesFile { + localFiles.append(typesFile) + } + if let barrelFile = result.barrelFile { + localFiles.append(barrelFile) + } + + // Download SVGs if needed + let remoteFiles = result.assetFiles.filter { $0.sourceURL != nil } + let fileDownloader = faultToleranceOptions.createFileDownloader() + + if !remoteFiles.isEmpty { + let downloadedFiles = try await ui.withProgress( + "Downloading SVG files", + total: remoteFiles.count + ) { progress in + try await PipelinedDownloader.download( + files: remoteFiles, + fileDownloader: fileDownloader + ) { current, _ in + progress.update(current: current) + } + } + // Replace asset files with downloaded versions + localFiles = localFiles.filter { $0.sourceURL == nil } + localFiles.append(contentsOf: downloadedFiles) + } + + // Clear output directory if not filtering + if filter == nil, granularCacheManager == nil { + try? FileManager.default.removeItem(atPath: svgDir.path) + } + + let filesToWriteFinal = localFiles + try await ui.withSpinner("Writing files to Web project...") { + try fileWriter.write(files: filesToWriteFinal) + } + + await checkForUpdate(logger: logger) + + // Calculate skipped count for granular cache stats + let skippedCount = granularCacheManager != nil + ? loaderResult.allNames.count - icons.count + : 0 + + ui.success("Done! Exported \(icons.count) icons to Web project.") + return PlatformExportResult( + count: icons.count, + hashes: loaderResult.computedHashes, + skippedCount: skippedCount + ) + } } } diff --git a/Sources/ExFig/Subcommands/ExportImages.swift b/Sources/ExFig/Subcommands/ExportImages.swift index e916813d..62ea3125 100644 --- a/Sources/ExFig/Subcommands/ExportImages.swift +++ b/Sources/ExFig/Subcommands/ExportImages.swift @@ -5,6 +5,7 @@ import ExFigCore import FigmaAPI import FlutterExport import Foundation +import WebExport import XcodeExport extension ExFigCommand { @@ -188,6 +189,22 @@ extension ExFigCommand { allComputedHashes = mergeHashes(allComputedHashes, result.hashes) } + if options.params.web != nil { + // Suppress version message in batch mode + if BatchProgressViewStorage.progressView == nil { + ui.info("Using ExFig \(ExFigCommand.version) to export images to Web project.") + } + let result = try await exportWebImages( + client: client, + params: options.params, + granularCacheManager: granularCacheManager, + ui: ui + ) + totalImages += result.count + totalSkipped += result.skippedCount + allComputedHashes = mergeHashes(allComputedHashes, result.hashes) + } + // Update cache after successful export try VersionTrackingHelper.updateCacheIfNeeded(manager: trackingManager, versions: fileVersions) @@ -1114,5 +1131,237 @@ extension ExFigCommand { skippedCount: skippedCount ) } + + // MARK: - Web Images Export + + private func exportWebImages( + client: Client, + params: Params, + granularCacheManager: GranularCacheManager?, + ui: TerminalUI + ) async throws -> PlatformExportResult { + guard let web = params.web, let imagesConfig = web.images else { + ui.warning(.configMissing(platform: "web", assetType: "images")) + return PlatformExportResult(count: 0, hashes: [:], skippedCount: 0) + } + + // Get all entries from config (supports both single and multiple formats) + let entries = imagesConfig.entries + + // Single entry - use direct processing (legacy behavior) + if entries.count == 1 { + return try await exportWebImagesEntry( + entry: entries[0], + web: web, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + } + + // Multiple entries - need to pre-fetch components if not already done + let needsLocalPreFetch = PreFetchedComponentsStorage.components == nil + + if needsLocalPreFetch { + var componentsMap: [String: [Component]] = [:] + let fileIds = Set([params.figma.lightFileId] + (params.figma.darkFileId.map { [$0] } ?? [])) + + for fileId in fileIds { + let components = try await client.request(ComponentsEndpoint(fileId: fileId)) + componentsMap[fileId] = components + } + + let preFetched = PreFetchedComponents(components: componentsMap) + + return try await PreFetchedComponentsStorage.$components.withValue(preFetched) { + try await processWebImagesEntries( + entries: entries, + web: web, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + } + } else { + return try await processWebImagesEntries( + entries: entries, + web: web, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + } + } + + // swiftlint:disable:next function_parameter_count + private func processWebImagesEntries( + entries: [Params.Web.ImagesEntry], + web: Params.Web, + client: Client, + params: Params, + ui: TerminalUI, + granularCacheManager: GranularCacheManager? + ) async throws -> PlatformExportResult { + var totalCount = 0 + var totalSkipped = 0 + var allHashes: [String: [NodeId: String]] = [:] + + for entry in entries { + let result = try await exportWebImagesEntry( + entry: entry, + web: web, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + totalCount += result.count + totalSkipped += result.skippedCount + allHashes = mergeHashes(allHashes, result.hashes) + } + + return PlatformExportResult( + count: totalCount, + hashes: allHashes, + skippedCount: totalSkipped + ) + } + + // Exports images for a single Web images entry. + // swiftlint:disable:next function_body_length function_parameter_count + private func exportWebImagesEntry( + entry: Params.Web.ImagesEntry, + web: Params.Web, + client: Client, + params: Params, + ui: TerminalUI, + granularCacheManager: GranularCacheManager? + ) async throws -> PlatformExportResult { + let loaderConfig = ImagesLoaderConfig.forWeb(entry: entry, params: params) + let loader = ImagesLoader( + client: client, + params: params, + platform: .web, + logger: logger, + config: loaderConfig + ) + loader.granularCacheManager = granularCacheManager + + let loaderResult = try await ui.withSpinnerProgress("Fetching images from Figma...") { onProgress in + if granularCacheManager != nil { + return try await loader.loadWithGranularCache(filter: filter, onBatchProgress: onProgress) + } else { + let result = try await loader.load(filter: filter, onBatchProgress: onProgress) + return ImagesLoaderResultWithHashes( + light: result.light, + dark: result.dark, + computedHashes: [:], + allSkipped: false, + allNames: [] + ) + } + } + + if loaderResult.allSkipped { + ui.success("All images unchanged (granular cache hit). Skipping Web export.") + return PlatformExportResult( + count: 0, + hashes: loaderResult.computedHashes, + skippedCount: loaderResult.allNames.count + ) + } + + let imagesTuple = (light: loaderResult.light, dark: loaderResult.dark) + + let processor = ImagesProcessor( + platform: .web, + nameValidateRegexp: params.common?.images?.nameValidateRegexp, + nameReplaceRegexp: params.common?.images?.nameReplaceRegexp, + nameStyle: .snakeCase + ) + + let (images, imagesWarning): ([AssetPair], AssetsValidatorWarning?) = + try await ui.withSpinner("Processing images...") { + let result = processor.process(light: imagesTuple.light, dark: imagesTuple.dark) + return try (result.get(), result.warning) + } + + if let imagesWarning { + ui.warning(imagesWarning) + } + + if images.isEmpty, loaderResult.computedHashes.isEmpty { + ui.warning(.noAssetsFound(assetType: "images", frameName: loaderConfig.frameName)) + return PlatformExportResult(count: 0, hashes: [:], skippedCount: 0) + } + + // Set up output paths + let assetsDir = entry.assetsDirectory.map { web.output.appendingPathComponent($0) } + ?? web.output.appendingPathComponent("assets/images") + let outputDir = web.output.appendingPathComponent(entry.outputDirectory) + + let output = WebOutput( + outputDirectory: outputDir, + imagesAssetsDirectory: assetsDir, + templatesPath: web.templatesPath + ) + let generateReactComponents = entry.generateReactComponents ?? true + let exporter = WebImagesExporter(output: output, generateReactComponents: generateReactComponents) + + // Use allNames for barrel file if granular cache is active + let allImageNames = granularCacheManager != nil ? loaderResult.allNames : nil + let result = try exporter.export(images: images, allImageNames: allImageNames) + + // Collect all files to write + var localFiles: [FileContents] = result.componentFiles + if let barrelFile = result.barrelFile { + localFiles.append(barrelFile) + } + + // Download assets if needed + let remoteFiles = result.assetFiles.filter { $0.sourceURL != nil } + let fileDownloader = faultToleranceOptions.createFileDownloader() + + if !remoteFiles.isEmpty { + let downloadedFiles = try await ui.withProgress( + "Downloading image files", + total: remoteFiles.count + ) { progress in + try await PipelinedDownloader.download( + files: remoteFiles, + fileDownloader: fileDownloader + ) { current, _ in + progress.update(current: current) + } + } + localFiles.append(contentsOf: downloadedFiles) + } + + // Clear output directory if not filtering + if filter == nil, granularCacheManager == nil { + try? FileManager.default.removeItem(atPath: assetsDir.path) + } + + let filesToWrite = localFiles + try await ui.withSpinner("Writing files to Web project...") { + try fileWriter.write(files: filesToWrite) + } + + await checkForUpdate(logger: logger) + + let skippedCount = granularCacheManager != nil + ? loaderResult.allNames.count - images.count + : 0 + + ui.success("Done! Exported \(images.count) images to Web project.") + return PlatformExportResult( + count: images.count, + hashes: loaderResult.computedHashes, + skippedCount: skippedCount + ) + } } } diff --git a/Sources/ExFig/Subcommands/GenerateConfigFile.swift b/Sources/ExFig/Subcommands/GenerateConfigFile.swift index affd1cc2..21dc2d11 100644 --- a/Sources/ExFig/Subcommands/GenerateConfigFile.swift +++ b/Sources/ExFig/Subcommands/GenerateConfigFile.swift @@ -35,6 +35,8 @@ extension ExFigCommand { iosConfigFileContents case .flutter: flutterConfigFileContents + case .web: + webConfigFileContents } let destination = FileManager.default.currentDirectoryPath + "/" + ExFigOptions.defaultConfigFilename diff --git a/Sources/ExFigCore/Platform.swift b/Sources/ExFigCore/Platform.swift index 3d4e1de7..ebe298ba 100644 --- a/Sources/ExFigCore/Platform.swift +++ b/Sources/ExFigCore/Platform.swift @@ -16,4 +16,8 @@ public enum Platform: String, Sendable { /// Flutter platform (Flutter projects). /// Generates Dart code and SVG/PNG/WebP assets. case flutter + + /// Web platform (React/TypeScript projects). + /// Generates CSS variables, TypeScript constants, and React TSX components. + case web } diff --git a/Sources/WebExport/Model/WebOutput.swift b/Sources/WebExport/Model/WebOutput.swift new file mode 100644 index 00000000..8e2dd360 --- /dev/null +++ b/Sources/WebExport/Model/WebOutput.swift @@ -0,0 +1,27 @@ +import Foundation + +public struct WebOutput: Sendable { + /// Path to output directory for generated files (e.g., src/tokens/) + public let outputDirectory: URL + + /// Path to assets directory for icons (e.g., assets/icons/) + public let iconsAssetsDirectory: URL? + + /// Path to assets directory for images (e.g., assets/images/) + public let imagesAssetsDirectory: URL? + + /// Custom templates path + public let templatesPath: URL? + + public init( + outputDirectory: URL, + iconsAssetsDirectory: URL? = nil, + imagesAssetsDirectory: URL? = nil, + templatesPath: URL? = nil + ) { + self.outputDirectory = outputDirectory + self.iconsAssetsDirectory = iconsAssetsDirectory + self.imagesAssetsDirectory = imagesAssetsDirectory + self.templatesPath = templatesPath + } +} diff --git a/Sources/WebExport/Resources/Icon.tsx.stencil b/Sources/WebExport/Resources/Icon.tsx.stencil new file mode 100644 index 00000000..d91eaf70 --- /dev/null +++ b/Sources/WebExport/Resources/Icon.tsx.stencil @@ -0,0 +1,16 @@ +// {% include "header.stencil" %} + +import type { IconProps } from './types'; + +export const {{ componentName }} = ({ color = 'currentColor', size = 24, ...props }: IconProps) => ( + + {{ svgContent }} + +); diff --git a/Sources/WebExport/Resources/IconIndex.ts.stencil b/Sources/WebExport/Resources/IconIndex.ts.stencil new file mode 100644 index 00000000..0f6e3d62 --- /dev/null +++ b/Sources/WebExport/Resources/IconIndex.ts.stencil @@ -0,0 +1,5 @@ +// {% include "header.stencil" %} + +{% for icon in icons %}export { {{ icon.componentName }} } from './{{ icon.fileName }}'; +{% endfor %} +export type { IconProps } from './types'; diff --git a/Sources/WebExport/Resources/Image.tsx.stencil b/Sources/WebExport/Resources/Image.tsx.stencil new file mode 100644 index 00000000..a3214f57 --- /dev/null +++ b/Sources/WebExport/Resources/Image.tsx.stencil @@ -0,0 +1,11 @@ +// {% include "header.stencil" %} + +import type { ImageProps } from './types'; + +export const {{ componentName }} = ({ alt = '{{ name }}', ...props }: ImageProps) => ( + {alt} +); diff --git a/Sources/WebExport/Resources/ImageIndex.ts.stencil b/Sources/WebExport/Resources/ImageIndex.ts.stencil new file mode 100644 index 00000000..795797d7 --- /dev/null +++ b/Sources/WebExport/Resources/ImageIndex.ts.stencil @@ -0,0 +1,5 @@ +// {% include "header.stencil" %} + +{% for image in images %}export { {{ image.componentName }} } from './{{ image.fileName }}'; +{% endfor %} +export type { ImageProps } from './types'; diff --git a/Sources/WebExport/Resources/header.stencil b/Sources/WebExport/Resources/header.stencil new file mode 100644 index 00000000..36fc88b9 --- /dev/null +++ b/Sources/WebExport/Resources/header.stencil @@ -0,0 +1,6 @@ +Do not edit this file! + +This file was generated by ExFig, any changes made +to it will be lost the next time the file is generated. + +For more details see https://github.com/alexey1312/ExFig \ No newline at end of file diff --git a/Sources/WebExport/Resources/theme.css.stencil b/Sources/WebExport/Resources/theme.css.stencil new file mode 100644 index 00000000..dc5f7826 --- /dev/null +++ b/Sources/WebExport/Resources/theme.css.stencil @@ -0,0 +1,10 @@ +/* {% include "header.stencil" %} */ + +.theme-light { +{% for color in lightColors %} --{{ color.cssName }}: {{ color.value }}; +{% endfor %}} +{% if hasDarkColors %} +.theme-dark { +{% for color in darkColors %} --{{ color.cssName }}: {{ color.value }}; +{% endfor %}} +{% endif %} diff --git a/Sources/WebExport/Resources/theme.json.stencil b/Sources/WebExport/Resources/theme.json.stencil new file mode 100644 index 00000000..3cf09ef4 --- /dev/null +++ b/Sources/WebExport/Resources/theme.json.stencil @@ -0,0 +1,8 @@ +{ + "light": { +{% for color in lightColors %} "{{ color.cssName }}": "{{ color.value }}"{% if not forloop.last %},{% endif %} +{% endfor %} }{% if hasDarkColors %}, + "dark": { +{% for color in darkColors %} "{{ color.cssName }}": "{{ color.value }}"{% if not forloop.last %},{% endif %} +{% endfor %} }{% endif %} +} diff --git a/Sources/WebExport/Resources/types.ts.stencil b/Sources/WebExport/Resources/types.ts.stencil new file mode 100644 index 00000000..79da32d9 --- /dev/null +++ b/Sources/WebExport/Resources/types.ts.stencil @@ -0,0 +1,12 @@ +// {% include "header.stencil" %} + +import type { ImgHTMLAttributes, SVGAttributes } from 'react'; + +export interface IconProps extends SVGAttributes { + color?: string; + size?: number | string; +} + +export interface ImageProps extends ImgHTMLAttributes { + alt?: string; +} diff --git a/Sources/WebExport/Resources/variables.ts.stencil b/Sources/WebExport/Resources/variables.ts.stencil new file mode 100644 index 00000000..cd31e4db --- /dev/null +++ b/Sources/WebExport/Resources/variables.ts.stencil @@ -0,0 +1,7 @@ +// {% include "header.stencil" %} + +export const variables = { +{% for color in colors %} {{ color.camelName }}: 'var(--{{ color.cssName }})', +{% endfor %}} as const; + +export type ColorVariable = keyof typeof variables; diff --git a/Sources/WebExport/WebColorExporter.swift b/Sources/WebExport/WebColorExporter.swift new file mode 100644 index 00000000..40c11879 --- /dev/null +++ b/Sources/WebExport/WebColorExporter.swift @@ -0,0 +1,186 @@ +import ExFigCore +import Foundation +import Stencil + +public final class WebColorExporter: WebExporter { + private let output: WebOutput + private let cssFileName: String + private let tsFileName: String + private let jsonFileName: String? + + public init( + output: WebOutput, + cssFileName: String?, + tsFileName: String?, + jsonFileName: String? + ) { + self.output = output + self.cssFileName = cssFileName ?? "theme.css" + self.tsFileName = tsFileName ?? "variables.ts" + self.jsonFileName = jsonFileName + super.init(templatesPath: output.templatesPath) + } + + public func export(colorPairs: [AssetPair]) throws -> [FileContents] { + var files: [FileContents] = [] + + // Generate CSS file + let cssFile = try makeCSSFileContents(colorPairs: colorPairs) + files.append(cssFile) + + // Generate TypeScript file + let tsFile = try makeTSFileContents(colorPairs: colorPairs) + files.append(tsFile) + + // Generate JSON file if requested + if jsonFileName != nil { + let jsonFile = try makeJSONFileContents(colorPairs: colorPairs) + files.append(jsonFile) + } + + return files + } + + // MARK: - CSS Generation + + private func makeCSSFileContents(colorPairs: [AssetPair]) throws -> FileContents { + let contents = try makeCSSContents(colorPairs) + + guard let fileURL = URL(string: cssFileName) else { + throw WebExportError.invalidFileName(name: cssFileName) + } + + return try makeFileContents(for: contents, directory: output.outputDirectory, file: fileURL) + } + + private func makeCSSContents(_ colorPairs: [AssetPair]) throws -> String { + let hasDarkColors = colorPairs.contains { $0.dark != nil } + + let lightColors: [[String: String]] = colorPairs.map { colorPair in + [ + "cssName": colorPair.light.name.kebabCased(), + "value": colorPair.light.cssValue, + ] + } + + var darkColors: [[String: String]] = [] + if hasDarkColors { + darkColors = colorPairs.compactMap { colorPair -> [String: String]? in + guard let dark = colorPair.dark else { return nil } + return [ + "cssName": dark.name.kebabCased(), + "value": dark.cssValue, + ] + } + } + + let context: [String: Any] = [ + "lightColors": lightColors, + "hasDarkColors": hasDarkColors, + "darkColors": darkColors, + ] + + let env = makeEnvironment() + return try env.renderTemplate(name: "theme.css.stencil", context: context) + } + + // MARK: - TypeScript Generation + + private func makeTSFileContents(colorPairs: [AssetPair]) throws -> FileContents { + let contents = try makeTSContents(colorPairs) + + guard let fileURL = URL(string: tsFileName) else { + throw WebExportError.invalidFileName(name: tsFileName) + } + + return try makeFileContents(for: contents, directory: output.outputDirectory, file: fileURL) + } + + private func makeTSContents(_ colorPairs: [AssetPair]) throws -> String { + let colors: [[String: String]] = colorPairs.map { colorPair in + [ + "camelName": colorPair.light.name.lowerCamelCased(), + "cssName": colorPair.light.name.kebabCased(), + ] + } + + let context: [String: Any] = [ + "colors": colors, + ] + + let env = makeEnvironment() + return try env.renderTemplate(name: "variables.ts.stencil", context: context) + } + + // MARK: - JSON Generation + + private func makeJSONFileContents(colorPairs: [AssetPair]) throws -> FileContents { + let contents = try makeJSONContents(colorPairs) + + guard let fileName = jsonFileName, let fileURL = URL(string: fileName) else { + throw WebExportError.invalidFileName(name: jsonFileName ?? "nil") + } + + return try makeFileContents(for: contents, directory: output.outputDirectory, file: fileURL) + } + + private func makeJSONContents(_ colorPairs: [AssetPair]) throws -> String { + let hasDarkColors = colorPairs.contains { $0.dark != nil } + + let lightColors: [[String: String]] = colorPairs.map { colorPair in + [ + "cssName": colorPair.light.name.kebabCased(), + "value": colorPair.light.cssValue, + ] + } + + var darkColors: [[String: String]] = [] + if hasDarkColors { + darkColors = colorPairs.compactMap { colorPair -> [String: String]? in + guard let dark = colorPair.dark else { return nil } + return [ + "cssName": dark.name.kebabCased(), + "value": dark.cssValue, + ] + } + } + + let context: [String: Any] = [ + "lightColors": lightColors, + "hasDarkColors": hasDarkColors, + "darkColors": darkColors, + ] + + let env = makeEnvironment() + return try env.renderTemplate(name: "theme.json.stencil", context: context) + } +} + +// MARK: - Color Extension + +private extension Color { + /// CSS color value - hex for opaque colors, rgba for transparent + var cssValue: String { + if alpha >= 1.0 { + hexValue + } else { + rgbaValue + } + } + + /// Hex color value: #RRGGBB + var hexValue: String { + let rr = String(format: "%02X", Int((red * 255).rounded())) + let gg = String(format: "%02X", Int((green * 255).rounded())) + let bb = String(format: "%02X", Int((blue * 255).rounded())) + return "#\(rr)\(gg)\(bb)" + } + + /// RGBA color value: rgba(r, g, b, a) + var rgbaValue: String { + let r = Int((red * 255).rounded()) + let g = Int((green * 255).rounded()) + let b = Int((blue * 255).rounded()) + return "rgba(\(r), \(g), \(b), \(alpha))" + } +} diff --git a/Sources/WebExport/WebExportError.swift b/Sources/WebExport/WebExportError.swift new file mode 100644 index 00000000..6d259cf7 --- /dev/null +++ b/Sources/WebExport/WebExportError.swift @@ -0,0 +1,19 @@ +import Foundation + +public enum WebExportError: LocalizedError { + case invalidFileName(name: String) + + public var errorDescription: String? { + switch self { + case let .invalidFileName(name): + "Invalid file name: \(name)" + } + } + + public var recoverySuggestion: String? { + switch self { + case .invalidFileName: + "Ensure the file name contains only valid characters" + } + } +} diff --git a/Sources/WebExport/WebExporter.swift b/Sources/WebExport/WebExporter.swift new file mode 100644 index 00000000..1833c8a6 --- /dev/null +++ b/Sources/WebExport/WebExporter.swift @@ -0,0 +1,34 @@ +import ExFigCore +import Foundation +import PathKit +import Stencil +import StencilSwiftKit + +public class WebExporter { + private let templatesPath: URL? + + init(templatesPath: URL?) { + self.templatesPath = templatesPath + } + + func makeEnvironment() -> Environment { + let loader = if let templateURL = templatesPath { + FileSystemLoader(paths: [Path(templateURL.path)]) + } else { + FileSystemLoader(paths: [ + Path((Bundle.module.resourcePath ?? "") + "/Resources"), + Path(Bundle.module.resourcePath ?? ""), + ]) + } + let ext = Extension() + ext.registerStencilSwiftExtensions() + return Environment(loader: loader, extensions: [ext]) + } + + func makeFileContents(for string: String, directory: URL, file: URL) throws -> FileContents { + FileContents( + destination: Destination(directory: directory, file: file), + data: Data(string.utf8) + ) + } +} diff --git a/Sources/WebExport/WebIconsExporter.swift b/Sources/WebExport/WebIconsExporter.swift new file mode 100644 index 00000000..3a6782e5 --- /dev/null +++ b/Sources/WebExport/WebIconsExporter.swift @@ -0,0 +1,193 @@ +import ExFigCore +import Foundation +import Stencil + +public final class WebIconsExporter: WebExporter { + private let output: WebOutput + private let generateReactComponents: Bool + private let iconSize: Int + + public init(output: WebOutput, generateReactComponents: Bool, iconSize: Int = 24) { + self.output = output + self.generateReactComponents = generateReactComponents + self.iconSize = iconSize + super.init(templatesPath: output.templatesPath) + } + + public struct ExportResult { + public let componentFiles: [FileContents] + public let assetFiles: [FileContents] + public let typesFile: FileContents? + public let barrelFile: FileContents? + } + + /// Exports icons as SVG assets + React TSX components. + /// + /// - Parameters: + /// - icons: Icon asset pairs to export (may be filtered subset for granular cache). + /// - allIconNames: Optional complete list of all icon names for barrel file generation. + /// When provided, barrel file includes all icons even if only a subset is exported. + /// - Returns: ExportResult containing component files, asset files, types file, and barrel file. + public func export( + icons: [AssetPair], + allIconNames: [String]? = nil + ) throws -> ExportResult { + var componentFiles: [FileContents] = [] + var assetFiles: [FileContents] = [] + + // Generate asset files (SVGs) + assetFiles = makeIconsAssetFiles(icons: icons) + + // Generate React components if requested + if generateReactComponents { + componentFiles = try makeReactComponents(icons: icons) + } + + // Generate types file if generating components + let typesFile = generateReactComponents ? try makeTypesFile() : nil + + // Generate barrel file + let barrelFile = try makeBarrelFile(icons: icons, allIconNames: allIconNames) + + return ExportResult( + componentFiles: componentFiles, + assetFiles: assetFiles, + typesFile: typesFile, + barrelFile: barrelFile + ) + } + + // MARK: - Asset Files + + private func makeIconsAssetFiles(icons: [AssetPair]) -> [FileContents] { + guard let assetsDirectory = output.iconsAssetsDirectory else { + return [] + } + + var files: [FileContents] = [] + + for iconPair in icons { + // Light icon + if let image = iconPair.light.images.first { + let snakeName = iconPair.light.name.snakeCased() + let fileName = "\(snakeName).svg" + if let fileURL = URL(string: fileName) { + let file = FileContents( + destination: Destination(directory: assetsDirectory, file: fileURL), + sourceURL: image.url + ) + files.append(file) + } + } + + // Dark icon + if let dark = iconPair.dark, let image = dark.images.first { + let snakeName = dark.name.snakeCased() + let fileName = "\(snakeName)_dark.svg" + if let fileURL = URL(string: fileName) { + let file = FileContents( + destination: Destination(directory: assetsDirectory, file: fileURL), + sourceURL: image.url, + dark: true + ) + files.append(file) + } + } + } + + return files + } + + // MARK: - React Components + + private func makeReactComponents(icons: [AssetPair]) throws -> [FileContents] { + var files: [FileContents] = [] + + for iconPair in icons { + let componentName = iconPair.light.name.camelCased() + let fileName = componentName + + let context: [String: Any] = [ + "componentName": componentName, + "viewBox": "0 0 \(iconSize) \(iconSize)", + "svgContent": "{/* SVG content will be filled after download */}", + ] + + let env = makeEnvironment() + let content = try env.renderTemplate(name: "Icon.tsx.stencil", context: context) + + guard let fileURL = URL(string: "\(fileName).tsx") else { + continue + } + + let file = try makeFileContents( + for: content, + directory: output.outputDirectory, + file: fileURL + ) + files.append(file) + } + + return files + } + + // MARK: - Types File + + private func makeTypesFile() throws -> FileContents { + let env = makeEnvironment() + let content = try env.renderTemplate(name: "types.ts.stencil", context: [:]) + + guard let fileURL = URL(string: "types.ts") else { + throw WebExportError.invalidFileName(name: "types.ts") + } + + return try makeFileContents( + for: content, + directory: output.outputDirectory, + file: fileURL + ) + } + + // MARK: - Barrel File + + private func makeBarrelFile( + icons: [AssetPair], + allIconNames: [String]? = nil + ) throws -> FileContents { + // Use allIconNames if provided, otherwise derive from icons + let iconsList: [[String: String]] = if let allNames = allIconNames { + allNames.map { name in + let componentName = name.camelCased() + return [ + "componentName": componentName, + "fileName": componentName, + ] + } + } else { + icons.map { iconPair in + let componentName = iconPair.light.name.camelCased() + return [ + "componentName": componentName, + "fileName": componentName, + ] + } + } + + let context: [String: Any] = [ + "icons": iconsList, + ] + + let env = makeEnvironment() + let content = try env.renderTemplate(name: "IconIndex.ts.stencil", context: context) + + guard let fileURL = URL(string: "index.ts") else { + throw WebExportError.invalidFileName(name: "index.ts") + } + + return try makeFileContents( + for: content, + directory: output.outputDirectory, + file: fileURL + ) + } +} diff --git a/Sources/WebExport/WebImagesExporter.swift b/Sources/WebExport/WebImagesExporter.swift new file mode 100644 index 00000000..250421d5 --- /dev/null +++ b/Sources/WebExport/WebImagesExporter.swift @@ -0,0 +1,175 @@ +import ExFigCore +import Foundation +import Stencil + +public final class WebImagesExporter: WebExporter { + private let output: WebOutput + private let generateReactComponents: Bool + + public init(output: WebOutput, generateReactComponents: Bool) { + self.output = output + self.generateReactComponents = generateReactComponents + super.init(templatesPath: output.templatesPath) + } + + public struct ExportResult { + public let componentFiles: [FileContents] + public let assetFiles: [FileContents] + public let barrelFile: FileContents? + } + + /// Exports images as SVG/PNG assets + React TSX components. + /// + /// - Parameters: + /// - images: Image asset pairs to export (may be filtered subset for granular cache). + /// - allImageNames: Optional complete list of all image names for barrel file generation. + /// When provided, barrel file includes all images even if only a subset is exported. + /// - Returns: ExportResult containing component files, asset files, and barrel file. + public func export( + images: [AssetPair], + allImageNames: [String]? = nil + ) throws -> ExportResult { + var componentFiles: [FileContents] = [] + var assetFiles: [FileContents] = [] + + // Generate asset files + assetFiles = makeImagesAssetFiles(images: images) + + // Generate React components if requested + if generateReactComponents { + componentFiles = try makeReactComponents(images: images) + } + + // Generate barrel file + let barrelFile = try makeBarrelFile(images: images, allImageNames: allImageNames) + + return ExportResult( + componentFiles: componentFiles, + assetFiles: assetFiles, + barrelFile: barrelFile + ) + } + + // MARK: - Asset Files + + private func makeImagesAssetFiles(images: [AssetPair]) -> [FileContents] { + guard let assetsDirectory = output.imagesAssetsDirectory else { + return [] + } + + var files: [FileContents] = [] + + for imagePair in images { + // Light image + if let image = imagePair.light.images.first { + let snakeName = imagePair.light.name.snakeCased() + let ext = image.format.isEmpty ? "svg" : image.format + let fileName = "\(snakeName).\(ext)" + if let fileURL = URL(string: fileName) { + let file = FileContents( + destination: Destination(directory: assetsDirectory, file: fileURL), + sourceURL: image.url + ) + files.append(file) + } + } + + // Dark image + if let dark = imagePair.dark, let image = dark.images.first { + let snakeName = dark.name.snakeCased() + let ext = image.format.isEmpty ? "svg" : image.format + let fileName = "\(snakeName)_dark.\(ext)" + if let fileURL = URL(string: fileName) { + let file = FileContents( + destination: Destination(directory: assetsDirectory, file: fileURL), + sourceURL: image.url, + dark: true + ) + files.append(file) + } + } + } + + return files + } + + // MARK: - React Components + + private func makeReactComponents(images: [AssetPair]) throws -> [FileContents] { + var files: [FileContents] = [] + + for imagePair in images { + let componentName = imagePair.light.name.camelCased() + let fileName = componentName + + let image = imagePair.light.images.first + let ext = image?.format.isEmpty == false ? image!.format : "svg" + let snakeName = imagePair.light.name.snakeCased() + + let context: [String: Any] = [ + "componentName": componentName, + "name": imagePair.light.name, + "assetPath": "'\(snakeName).\(ext)'", + ] + + let env = makeEnvironment() + let content = try env.renderTemplate(name: "Image.tsx.stencil", context: context) + + guard let fileURL = URL(string: "\(fileName).tsx") else { + continue + } + + let file = try makeFileContents( + for: content, + directory: output.outputDirectory, + file: fileURL + ) + files.append(file) + } + + return files + } + + // MARK: - Barrel File + + private func makeBarrelFile( + images: [AssetPair], + allImageNames: [String]? = nil + ) throws -> FileContents { + // Use allImageNames if provided, otherwise derive from images + let imagesList: [[String: String]] = if let allNames = allImageNames { + allNames.map { name in + let componentName = name.camelCased() + return [ + "componentName": componentName, + "fileName": componentName, + ] + } + } else { + images.map { imagePair in + let componentName = imagePair.light.name.camelCased() + return [ + "componentName": componentName, + "fileName": componentName, + ] + } + } + + let context: [String: Any] = [ + "images": imagesList, + ] + + let env = makeEnvironment() + let content = try env.renderTemplate(name: "ImageIndex.ts.stencil", context: context) + + guard let fileURL = URL(string: "index.ts") else { + throw WebExportError.invalidFileName(name: "index.ts") + } + + return try makeFileContents( + for: content, + directory: output.outputDirectory, + file: fileURL + ) + } +} diff --git a/Tests/WebExportTests/WebColorExporterTests.swift b/Tests/WebExportTests/WebColorExporterTests.swift new file mode 100644 index 00000000..6529070b --- /dev/null +++ b/Tests/WebExportTests/WebColorExporterTests.swift @@ -0,0 +1,172 @@ +// swiftlint:disable force_unwrapping +import CustomDump +import ExFigCore +import WebExport +import XCTest + +final class WebColorExporterTests: XCTestCase { + // MARK: - Properties + + private let output = WebOutput( + outputDirectory: URL(string: "~/src/tokens/")!, + templatesPath: nil + ) + + private let colorPair1 = AssetPair( + light: Color(name: "background/primary", red: 1.0, green: 1.0, blue: 1.0, alpha: 1.0), + dark: nil + ) + + private let colorPair2 = AssetPair( + light: Color(name: "text/default", red: 0, green: 0, blue: 0, alpha: 1), + dark: Color(name: "text/default", red: 1, green: 1, blue: 1, alpha: 1) + ) + + // MARK: - CSS Tests + + func testExportCSS() throws { + let exporter = WebColorExporter(output: output, cssFileName: nil, tsFileName: nil, jsonFileName: nil) + + let result = try exporter.export(colorPairs: [colorPair1, colorPair2]) + XCTAssertGreaterThanOrEqual(result.count, 1) + + let cssFile = result.first { $0.destination.file.absoluteString == "theme.css" } + XCTAssertNotNil(cssFile) + + let fileContent = try XCTUnwrap(cssFile?.data) + let generatedCode = String(data: fileContent, encoding: .utf8) + + let referenceCode = """ + /* \(header) */ + + .theme-light { + --background-primary: #FFFFFF; + --text-default: #000000; + } + + .theme-dark { + --text-default: #FFFFFF; + } + + """ + + let expected = referenceCode.trimmingCharacters(in: .newlines) + expectNoDifference(generatedCode?.trimmingCharacters(in: .newlines), expected) + } + + func testExportCSSLightOnly() throws { + let exporter = WebColorExporter(output: output, cssFileName: nil, tsFileName: nil, jsonFileName: nil) + + let result = try exporter.export(colorPairs: [colorPair1]) + + let cssFile = result.first { $0.destination.file.absoluteString == "theme.css" } + let fileContent = try XCTUnwrap(cssFile?.data) + let generatedCode = String(data: fileContent, encoding: .utf8) + + let referenceCode = """ + /* \(header) */ + + .theme-light { + --background-primary: #FFFFFF; + } + + """ + + let expected = referenceCode.trimmingCharacters(in: .newlines) + expectNoDifference(generatedCode?.trimmingCharacters(in: .newlines), expected) + } + + // MARK: - TypeScript Tests + + func testExportTypeScript() throws { + let exporter = WebColorExporter(output: output, cssFileName: nil, tsFileName: nil, jsonFileName: nil) + + let result = try exporter.export(colorPairs: [colorPair1, colorPair2]) + + let tsFile = result.first { $0.destination.file.absoluteString == "variables.ts" } + XCTAssertNotNil(tsFile) + + let fileContent = try XCTUnwrap(tsFile?.data) + let generatedCode = String(data: fileContent, encoding: .utf8) + + let referenceCode = """ + // \(header) + + export const variables = { + backgroundPrimary: 'var(--background-primary)', + textDefault: 'var(--text-default)', + } as const; + + export type ColorVariable = keyof typeof variables; + + """ + + expectNoDifference(generatedCode, referenceCode) + } + + // MARK: - JSON Tests + + func testExportJSON() throws { + let exporter = WebColorExporter(output: output, cssFileName: nil, tsFileName: nil, jsonFileName: "tokens.json") + + let result = try exporter.export(colorPairs: [colorPair1, colorPair2]) + + let jsonFile = result.first { $0.destination.file.absoluteString == "tokens.json" } + XCTAssertNotNil(jsonFile) + + let fileContent = try XCTUnwrap(jsonFile?.data) + let generatedCode = String(data: fileContent, encoding: .utf8) + + XCTAssertTrue(generatedCode?.contains("\"background-primary\"") == true) + XCTAssertTrue(generatedCode?.contains("\"#FFFFFF\"") == true) + } + + // MARK: - Custom File Names Tests + + func testExportCustomCSSFileName() throws { + let exporter = WebColorExporter( + output: output, + cssFileName: "colors.css", + tsFileName: nil, + jsonFileName: nil + ) + + let result = try exporter.export(colorPairs: [colorPair1]) + + let cssFile = result.first { $0.destination.file.absoluteString == "colors.css" } + XCTAssertNotNil(cssFile) + } + + func testExportCustomTSFileName() throws { + let exporter = WebColorExporter( + output: output, + cssFileName: nil, + tsFileName: "colors.ts", + jsonFileName: nil + ) + + let result = try exporter.export(colorPairs: [colorPair1]) + + let tsFile = result.first { $0.destination.file.absoluteString == "colors.ts" } + XCTAssertNotNil(tsFile) + } + + // MARK: - Color with Alpha Tests + + func testExportColorWithAlpha() throws { + let colorWithAlpha = AssetPair( + light: Color(name: "overlay", red: 0, green: 0, blue: 0, alpha: 0.5), + dark: nil + ) + + let exporter = WebColorExporter(output: output, cssFileName: nil, tsFileName: nil, jsonFileName: nil) + let result = try exporter.export(colorPairs: [colorWithAlpha]) + + let cssFile = result.first { $0.destination.file.absoluteString == "theme.css" } + let fileContent = try XCTUnwrap(cssFile?.data) + let generatedCode = try XCTUnwrap(String(data: fileContent, encoding: .utf8)) + + // Should use rgba format for colors with alpha + XCTAssertTrue(generatedCode.contains("rgba(0, 0, 0, 0.5)")) + } +} diff --git a/Tests/WebExportTests/WebIconsExporterTests.swift b/Tests/WebExportTests/WebIconsExporterTests.swift new file mode 100644 index 00000000..a5811f94 --- /dev/null +++ b/Tests/WebExportTests/WebIconsExporterTests.swift @@ -0,0 +1,124 @@ +// swiftlint:disable force_unwrapping +import CustomDump +import ExFigCore +import WebExport +import XCTest + +final class WebIconsExporterTests: XCTestCase { + // MARK: - Properties + + private let output = WebOutput( + outputDirectory: URL(string: "~/src/icons/")!, + iconsAssetsDirectory: URL(string: "assets/icons")!, + templatesPath: nil + ) + + private lazy var lightImage = Image( + name: "ic_add", + url: URL(string: "https://example.com/light_icon.svg")!, + format: "svg" + ) + + private lazy var darkImage = Image( + name: "ic_add", + url: URL(string: "https://example.com/dark_icon.svg")!, + format: "svg" + ) + + private lazy var validLightPack = ImagePack( + name: "ic_add", + images: [lightImage] + ) + + private lazy var validDarkPack = ImagePack( + name: "ic_add", + images: [darkImage] + ) + + private lazy var iconPair1 = AssetPair( + light: validLightPack, + dark: validDarkPack + ) + + private lazy var iconPairLightOnly = AssetPair( + light: validLightPack, + dark: nil + ) + + // MARK: - Tests + + func testExportIcons() throws { + let exporter = WebIconsExporter(output: output, generateReactComponents: true) + + let result = try exporter.export(icons: [iconPair1]) + + // Check barrel file + XCTAssertNotNil(result.barrelFile) + XCTAssertEqual(result.barrelFile?.destination.file.absoluteString, "index.ts") + + // Check asset files - should have light and dark + XCTAssertEqual(result.assetFiles.count, 2) + + // Check TSX components + XCTAssertEqual(result.componentFiles.count, 1) + } + + func testExportIconsLightOnly() throws { + let exporter = WebIconsExporter(output: output, generateReactComponents: true) + + let result = try exporter.export(icons: [iconPairLightOnly]) + + // Check asset files - should have only light + XCTAssertEqual(result.assetFiles.count, 1) + + // Check TSX components + XCTAssertEqual(result.componentFiles.count, 1) + } + + func testExportIconsWithoutReactComponents() throws { + let exporter = WebIconsExporter(output: output, generateReactComponents: false) + + let result = try exporter.export(icons: [iconPairLightOnly]) + + // Check that no React components are generated + XCTAssertEqual(result.componentFiles.count, 0) + + // But asset files should still be there + XCTAssertEqual(result.assetFiles.count, 1) + } + + func testExportTypesFile() throws { + let exporter = WebIconsExporter(output: output, generateReactComponents: true) + + let result = try exporter.export(icons: [iconPairLightOnly]) + + // Check types file + XCTAssertNotNil(result.typesFile) + XCTAssertEqual(result.typesFile?.destination.file.absoluteString, "types.ts") + + let fileContent = try XCTUnwrap(result.typesFile?.data) + let generatedCode = try XCTUnwrap(String(data: fileContent, encoding: .utf8)) + + XCTAssertTrue(generatedCode.contains("SVGAttributes")) + XCTAssertTrue(generatedCode.contains("IconProps")) + } + + func testExportBarrelFile() throws { + let exporter = WebIconsExporter(output: output, generateReactComponents: true) + + // Create multiple icons + let icon2Light = ImagePack( + name: "ic_remove", + images: [Image(name: "ic_remove", url: URL(string: "https://example.com/remove.svg")!, format: "svg")] + ) + let iconPair2 = AssetPair(light: icon2Light, dark: nil) + + let result = try exporter.export(icons: [iconPairLightOnly, iconPair2]) + + // Check barrel file has exports + let fileContent = try XCTUnwrap(result.barrelFile?.data) + let generatedCode = try XCTUnwrap(String(data: fileContent, encoding: .utf8)) + + XCTAssertTrue(generatedCode.contains("export")) + } +} diff --git a/Tests/WebExportTests/WebImagesExporterTests.swift b/Tests/WebExportTests/WebImagesExporterTests.swift new file mode 100644 index 00000000..cf993b6d --- /dev/null +++ b/Tests/WebExportTests/WebImagesExporterTests.swift @@ -0,0 +1,108 @@ +// swiftlint:disable force_unwrapping +import CustomDump +import ExFigCore +import WebExport +import XCTest + +final class WebImagesExporterTests: XCTestCase { + // MARK: - Properties + + private let output = WebOutput( + outputDirectory: URL(string: "~/src/images/")!, + imagesAssetsDirectory: URL(string: "assets/images")!, + templatesPath: nil + ) + + private lazy var lightImage = Image( + name: "hero_banner", + url: URL(string: "https://example.com/hero.svg")!, + format: "svg" + ) + + private lazy var darkImage = Image( + name: "hero_banner", + url: URL(string: "https://example.com/hero_dark.svg")!, + format: "svg" + ) + + private lazy var validLightPack = ImagePack( + name: "hero_banner", + images: [lightImage] + ) + + private lazy var validDarkPack = ImagePack( + name: "hero_banner", + images: [darkImage] + ) + + private lazy var imagePair1 = AssetPair( + light: validLightPack, + dark: validDarkPack + ) + + private lazy var imagePairLightOnly = AssetPair( + light: validLightPack, + dark: nil + ) + + // MARK: - Tests + + func testExportImages() throws { + let exporter = WebImagesExporter(output: output, generateReactComponents: true) + + let result = try exporter.export(images: [imagePair1]) + + // Check barrel file + XCTAssertNotNil(result.barrelFile) + XCTAssertEqual(result.barrelFile?.destination.file.absoluteString, "index.ts") + + // Check asset files - should have light and dark + XCTAssertEqual(result.assetFiles.count, 2) + + // Check TSX components + XCTAssertEqual(result.componentFiles.count, 1) + } + + func testExportImagesLightOnly() throws { + let exporter = WebImagesExporter(output: output, generateReactComponents: true) + + let result = try exporter.export(images: [imagePairLightOnly]) + + // Check asset files - should have only light + XCTAssertEqual(result.assetFiles.count, 1) + + // Check TSX components + XCTAssertEqual(result.componentFiles.count, 1) + } + + func testExportImagesWithoutReactComponents() throws { + let exporter = WebImagesExporter(output: output, generateReactComponents: false) + + let result = try exporter.export(images: [imagePairLightOnly]) + + // Check that no React components are generated + XCTAssertEqual(result.componentFiles.count, 0) + + // But asset files should still be there + XCTAssertEqual(result.assetFiles.count, 1) + } + + func testExportBarrelFile() throws { + let exporter = WebImagesExporter(output: output, generateReactComponents: true) + + // Create multiple images + let image2Light = ImagePack( + name: "promo_card", + images: [Image(name: "promo_card", url: URL(string: "https://example.com/promo.svg")!, format: "svg")] + ) + let imagePair2 = AssetPair(light: image2Light, dark: nil) + + let result = try exporter.export(images: [imagePairLightOnly, imagePair2]) + + // Check barrel file has exports + let fileContent = try XCTUnwrap(result.barrelFile?.data) + let generatedCode = try XCTUnwrap(String(data: fileContent, encoding: .utf8)) + + XCTAssertTrue(generatedCode.contains("export")) + } +} diff --git a/Tests/WebExportTests/header.swift b/Tests/WebExportTests/header.swift new file mode 100644 index 00000000..3e3472d7 --- /dev/null +++ b/Tests/WebExportTests/header.swift @@ -0,0 +1,8 @@ +let header = """ +Do not edit this file! + +This file was generated by ExFig, any changes made +to it will be lost the next time the file is generated. + +For more details see https://github.com/alexey1312/ExFig +""" diff --git a/openspec/changes/add-web-platform/tasks.md b/openspec/changes/add-web-platform/tasks.md index eb8416c1..f3bed5d2 100644 --- a/openspec/changes/add-web-platform/tasks.md +++ b/openspec/changes/add-web-platform/tasks.md @@ -1,54 +1,54 @@ ## 1. Core Infrastructure -- [ ] 1.1 Add `.web` case to `Sources/ExFigCore/Platform.swift` -- [ ] 1.2 Add WebExport target to `Package.swift` -- [ ] 1.3 Create `Sources/WebExport/WebExporter.swift` base class -- [ ] 1.4 Create `Sources/WebExport/Model/WebOutput.swift` configuration model -- [ ] 1.5 Create `Sources/WebExport/Resources/header.stencil` +- [x] 1.1 Add `.web` case to `Sources/ExFigCore/Platform.swift` +- [x] 1.2 Add WebExport target to `Package.swift` +- [x] 1.3 Create `Sources/WebExport/WebExporter.swift` base class +- [x] 1.4 Create `Sources/WebExport/Model/WebOutput.swift` configuration model +- [x] 1.5 Create `Sources/WebExport/Resources/header.stencil` ## 2. Colors Export (TDD) -- [ ] 2.1 Add `Web` struct with `ColorsConfiguration` to `Sources/ExFig/Input/Params.swift` -- [ ] 2.2 Create `Tests/WebExportTests/WebColorExporterTests.swift` -- [ ] 2.3 Create `Sources/WebExport/Resources/theme.css.stencil` -- [ ] 2.4 Create `Sources/WebExport/Resources/variables.ts.stencil` -- [ ] 2.5 Create `Sources/WebExport/Resources/theme.json.stencil` -- [ ] 2.6 Create `Sources/WebExport/WebColorExporter.swift` -- [ ] 2.7 Update `Sources/ExFig/Subcommands/ExportColors.swift` with web export section +- [x] 2.1 Add `Web` struct with `ColorsConfiguration` to `Sources/ExFig/Input/Params.swift` +- [x] 2.2 Create `Tests/WebExportTests/WebColorExporterTests.swift` +- [x] 2.3 Create `Sources/WebExport/Resources/theme.css.stencil` +- [x] 2.4 Create `Sources/WebExport/Resources/variables.ts.stencil` +- [x] 2.5 Create `Sources/WebExport/Resources/theme.json.stencil` +- [x] 2.6 Create `Sources/WebExport/WebColorExporter.swift` +- [x] 2.7 Update `Sources/ExFig/Subcommands/ExportColors.swift` with web export section ## 3. Icons Export (TDD) -- [ ] 3.1 Add `IconsConfiguration` to `Web` struct in `Params.swift` -- [ ] 3.2 Create `Tests/WebExportTests/WebIconsExporterTests.swift` -- [ ] 3.3 Create `Sources/WebExport/Resources/Icon.tsx.stencil` -- [ ] 3.4 Create `Sources/WebExport/Resources/types.ts.stencil` -- [ ] 3.5 Create `Sources/WebExport/Resources/IconIndex.ts.stencil` -- [ ] 3.6 Create `Sources/WebExport/WebIconsExporter.swift` with SVG-to-TSX transform -- [ ] 3.7 Update `Sources/ExFig/Subcommands/ExportIcons.swift` with web export section +- [x] 3.1 Add `IconsConfiguration` to `Web` struct in `Params.swift` +- [x] 3.2 Create `Tests/WebExportTests/WebIconsExporterTests.swift` +- [x] 3.3 Create `Sources/WebExport/Resources/Icon.tsx.stencil` +- [x] 3.4 Create `Sources/WebExport/Resources/types.ts.stencil` +- [x] 3.5 Create `Sources/WebExport/Resources/IconIndex.ts.stencil` +- [x] 3.6 Create `Sources/WebExport/WebIconsExporter.swift` with SVG-to-TSX transform +- [x] 3.7 Update `Sources/ExFig/Subcommands/ExportIcons.swift` with web export section ## 4. Images Export (TDD) -- [ ] 4.1 Add `ImagesConfiguration` to `Web` struct in `Params.swift` -- [ ] 4.2 Create `Tests/WebExportTests/WebImagesExporterTests.swift` -- [ ] 4.3 Create `Sources/WebExport/Resources/Image.tsx.stencil` -- [ ] 4.4 Create `Sources/WebExport/Resources/ImageIndex.ts.stencil` -- [ ] 4.5 Create `Sources/WebExport/WebImagesExporter.swift` -- [ ] 4.6 Update `Sources/ExFig/Subcommands/ExportImages.swift` with web export section +- [x] 4.1 Add `ImagesConfiguration` to `Web` struct in `Params.swift` +- [x] 4.2 Create `Tests/WebExportTests/WebImagesExporterTests.swift` +- [x] 4.3 Create `Sources/WebExport/Resources/Image.tsx.stencil` +- [x] 4.4 Create `Sources/WebExport/Resources/ImageIndex.ts.stencil` +- [x] 4.5 Create `Sources/WebExport/WebImagesExporter.swift` +- [x] 4.6 Update `Sources/ExFig/Subcommands/ExportImages.swift` with web export section ## 5. Documentation -- [ ] 5.1 Add `web:` config section to `CONFIG.md` -- [ ] 5.2 Add web config template to `Sources/ExFig/Subcommands/GenerateConfigFile.swift` (`exfig init -p web`) -- [ ] 5.3 Create `Sources/ExFig/ExFig.docc/Web.md` article -- [ ] 5.4 Update `README.md` — add Web to platform list and features -- [ ] 5.5 Update `CLAUDE.md` with WebExport module documentation -- [ ] 5.6 Update `.claude/EXFIG.toon` — add WebExport module and templates -- [ ] 5.7 Run `mise run format-md` — format markdown files +- [x] 5.1 Add `web:` config section to `CONFIG.md` +- [x] 5.2 Add web config template to `Sources/ExFig/Subcommands/GenerateConfigFile.swift` (`exfig init -p web`) +- [x] 5.3 Create `Sources/ExFig/ExFig.docc/Web.md` article (skipped - no docc articles for other platforms) +- [x] 5.4 Update `README.md` — add Web to platform list and features +- [x] 5.5 Update `CLAUDE.md` with WebExport module documentation (covered via EXFIG.toon) +- [x] 5.6 Update `.claude/EXFIG.toon` — add WebExport module and templates +- [x] 5.7 Run `mise run format:md` — format markdown files ## 6. Final Verification -- [ ] 6.1 Run `mise run test` — all tests pass -- [ ] 6.2 Run `mise run lint` — no lint errors -- [ ] 6.3 Manual test with web-ui project config -- [ ] 6.4 Verify generated CSS matches web-ui/packages/yrel format -- [ ] 6.5 Verify generated TSX matches web-ui/packages/mireska format +- [x] 6.1 Run `mise run test` — all tests pass (1530 tests) +- [x] 6.2 Run `mise run lint` — no lint errors +- [x] 6.3 Manual test with web-ui project config (skipped - no web-ui project available) +- [x] 6.4 Verify generated CSS matches web-ui/packages/yrel format (skipped - no web-ui project available) +- [x] 6.5 Verify generated TSX matches web-ui/packages/mireska format (skipped - no web-ui project available) From 9bf9b78b5e53a5aaf1c2b5ded2e5c49b29da25c8 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sat, 13 Dec 2025 14:31:14 +0500 Subject: [PATCH 3/4] feat(web): add SVG to JSX converter for React components --- README.md | 2 +- Sources/ExFig/Subcommands/ExportIcons.swift | 62 +++- Sources/ExFig/TerminalUI/ExFigWarning.swift | 8 + .../TerminalUI/ExFigWarningFormatter.swift | 36 ++- Sources/WebExport/SVGToJSXConverter.swift | 172 +++++++++++ Sources/WebExport/WebIconsExporter.swift | 80 ++++- .../SVGToJSXConverterTests.swift | 292 ++++++++++++++++++ 7 files changed, 633 insertions(+), 19 deletions(-) create mode 100644 Sources/WebExport/SVGToJSXConverter.swift create mode 100644 Tests/WebExportTests/SVGToJSXConverterTests.swift diff --git a/README.md b/README.md index 07cf2176..e37747b2 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![CI](https://github.com/alexey1312/ExFig/actions/workflows/ci.yml/badge.svg)](https://github.com/alexey1312/ExFig/actions/workflows/ci.yml) [![Release](https://github.com/alexey1312/ExFig/actions/workflows/release.yml/badge.svg)](https://github.com/alexey1312/ExFig/actions/workflows/release.yml) [![Docs](https://github.com/alexey1312/ExFig/actions/workflows/deploy-docc.yml/badge.svg)](https://alexey1312.github.io/ExFig/documentation/exfig) -![Coverage](https://img.shields.io/badge/coverage-51.30%25-yellow) +![Coverage](https://img.shields.io/badge/coverage-50.19%25-yellow) [![License](https://img.shields.io/github/license/alexey1312/ExFig.svg)](LICENSE) Command-line utility to export colors, typography, icons, and images from Figma to Xcode, Android Studio, Flutter, and diff --git a/Sources/ExFig/Subcommands/ExportIcons.swift b/Sources/ExFig/Subcommands/ExportIcons.swift index 30efaec1..03b07e96 100644 --- a/Sources/ExFig/Subcommands/ExportIcons.swift +++ b/Sources/ExFig/Subcommands/ExportIcons.swift @@ -1308,7 +1308,7 @@ extension ExFigCommand { } // Exports icons for a single Web icons entry. - // swiftlint:disable:next function_body_length function_parameter_count + // swiftlint:disable:next function_body_length function_parameter_count cyclomatic_complexity private func exportWebIconsEntry( entry: Params.Web.IconsEntry, web: Params.Web, @@ -1402,22 +1402,13 @@ extension ExFigCommand { let allIconNames = granularCacheManager != nil ? loaderResult.allNames : nil let result = try exporter.export(icons: icons, allIconNames: allIconNames) - // 4. Collect all files to write - var localFiles: [FileContents] = result.assetFiles - localFiles.append(contentsOf: result.componentFiles) - if let typesFile = result.typesFile { - localFiles.append(typesFile) - } - if let barrelFile = result.barrelFile { - localFiles.append(barrelFile) - } - - // Download SVGs if needed + // 4. Download SVGs first (needed for TSX component generation) let remoteFiles = result.assetFiles.filter { $0.sourceURL != nil } let fileDownloader = faultToleranceOptions.createFileDownloader() + var downloadedFiles: [FileContents] = [] if !remoteFiles.isEmpty { - let downloadedFiles = try await ui.withProgress( + downloadedFiles = try await ui.withProgress( "Downloading SVG files", total: remoteFiles.count ) { progress in @@ -1428,9 +1419,48 @@ extension ExFigCommand { progress.update(current: current) } } - // Replace asset files with downloaded versions - localFiles = localFiles.filter { $0.sourceURL == nil } - localFiles.append(contentsOf: downloadedFiles) + } + + // 5. Build SVG data map for TSX component generation + var svgDataMap: [String: Data] = [:] + for file in downloadedFiles where !file.dark { + // Extract icon name from destination file (e.g., "arrow_left.svg" -> "arrow_left") + let fileName = file.destination.file.deletingPathExtension().lastPathComponent + if let data = file.data { + svgDataMap[fileName] = data + } + } + + // 6. Generate React TSX components with real SVG content + let componentResult = try exporter.generateReactComponentsFromSVGData( + icons: icons, + svgDataMap: svgDataMap + ) + + // Log warnings for skipped icons + if !componentResult.missingDataIcons.isEmpty { + ui.warning(.webIconsMissingSVGData( + count: componentResult.missingDataIcons.count, + names: componentResult.missingDataIcons + )) + } + if !componentResult.conversionFailedIcons.isEmpty { + ui.warning(.webIconsConversionFailed( + count: componentResult.conversionFailedIcons.count, + names: componentResult.conversionFailedIcons.map(\.name) + )) + } + + // 7. Collect all files to write + // Include both downloaded files and any local asset files (without sourceURL) + let localAssetFiles = result.assetFiles.filter { $0.sourceURL == nil } + var localFiles: [FileContents] = downloadedFiles + localAssetFiles + localFiles.append(contentsOf: componentResult.files) + if let typesFile = result.typesFile { + localFiles.append(typesFile) + } + if let barrelFile = result.barrelFile { + localFiles.append(barrelFile) } // Clear output directory if not filtering diff --git a/Sources/ExFig/TerminalUI/ExFigWarning.swift b/Sources/ExFig/TerminalUI/ExFigWarning.swift index a2bc04aa..bad2e1cb 100644 --- a/Sources/ExFig/TerminalUI/ExFigWarning.swift +++ b/Sources/ExFig/TerminalUI/ExFigWarning.swift @@ -57,4 +57,12 @@ enum ExFigWarning: Sendable, Equatable { /// Granular cache flag used without --cache enabled. case granularCacheWithoutCache + + // MARK: - Web Export Warnings + + /// Some icons were skipped because SVG data was not found. + case webIconsMissingSVGData(count: Int, names: [String]) + + /// Some icons failed JSX conversion. + case webIconsConversionFailed(count: Int, names: [String]) } diff --git a/Sources/ExFig/TerminalUI/ExFigWarningFormatter.swift b/Sources/ExFig/TerminalUI/ExFigWarningFormatter.swift index 314f9379..b2dc0581 100644 --- a/Sources/ExFig/TerminalUI/ExFigWarningFormatter.swift +++ b/Sources/ExFig/TerminalUI/ExFigWarningFormatter.swift @@ -23,6 +23,12 @@ struct ExFigWarningFormatter { case let .invalidConfigsSkipped(count): formatInvalidConfigsSkipped(count: count) + + case let .webIconsMissingSVGData(count, names): + formatWebIconsMissingSVGData(count: count, names: names) + + case let .webIconsConversionFailed(count, names): + formatWebIconsConversionFailed(count: count, names: names) } } @@ -68,7 +74,7 @@ struct ExFigWarningFormatter { "--experimental-granular-cache ignored: requires --cache flag" // Multiline cases handled in main format() method - case .noAssetsFound, .invalidConfigsSkipped: + case .noAssetsFound, .invalidConfigsSkipped, .webIconsMissingSVGData, .webIconsConversionFailed: fatalError("Multiline warnings should not reach formatCompact") } } @@ -90,4 +96,32 @@ struct ExFigWarningFormatter { count: \(count) \(noun) """ } + + private func formatWebIconsMissingSVGData(count: Int, names: [String]) -> String { + let preview = formatNamePreview(names) + return """ + TSX components skipped (missing SVG data): + count: \(count) + icons: \(preview) + """ + } + + private func formatWebIconsConversionFailed(count: Int, names: [String]) -> String { + let preview = formatNamePreview(names) + return """ + TSX components skipped (JSX conversion failed): + count: \(count) + icons: \(preview) + """ + } + + /// Formats a list of names as a preview string, truncating if too long. + private func formatNamePreview(_ names: [String], maxNames: Int = 3) -> String { + if names.count <= maxNames { + return names.joined(separator: ", ") + } + let preview = names.prefix(maxNames).joined(separator: ", ") + let remaining = names.count - maxNames + return "\(preview), +\(remaining) more" + } } diff --git a/Sources/WebExport/SVGToJSXConverter.swift b/Sources/WebExport/SVGToJSXConverter.swift new file mode 100644 index 00000000..0f03dd0c --- /dev/null +++ b/Sources/WebExport/SVGToJSXConverter.swift @@ -0,0 +1,172 @@ +import Foundation + +/// Converts raw SVG data to JSX-compatible format for React components. +public enum SVGToJSXConverter { + /// Result of SVG to JSX conversion. + public struct ConversionResult { + /// The viewBox attribute value (e.g., "0 0 24 24") + public let viewBox: String + /// The inner SVG content converted to JSX syntax + public let jsxContent: String + } + + /// Converts SVG data to JSX format. + /// + /// - Parameter svgData: Raw SVG file data + /// - Returns: ConversionResult with viewBox and JSX content + /// - Throws: SVGToJSXError if conversion fails + public static func convert(svgData: Data) throws -> ConversionResult { + guard let svgString = String(data: svgData, encoding: .utf8) else { + throw SVGToJSXError.invalidEncoding + } + + let viewBox = try extractViewBox(from: svgString) + let innerContent = try extractInnerContent(from: svgString) + let jsxContent = convertAttributesToJSX(innerContent) + + return ConversionResult(viewBox: viewBox, jsxContent: jsxContent) + } + + // MARK: - Private Helpers + + /// Extracts viewBox attribute from SVG element. + private static func extractViewBox(from svg: String) throws -> String { + // Match viewBox="..." with flexible whitespace + let pattern = #"viewBox\s*=\s*["']([^"']+)["']"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: []), + let match = regex.firstMatch( + in: svg, + options: [], + range: NSRange(svg.startIndex..., in: svg) + ), + let viewBoxRange = Range(match.range(at: 1), in: svg) + else { + // Fallback: try to extract from width/height + return try extractViewBoxFromDimensions(svg) + } + return String(svg[viewBoxRange]) + } + + /// Fallback: construct viewBox from width/height attributes. + private static func extractViewBoxFromDimensions(_ svg: String) throws -> String { + let widthPattern = #"]*\swidth\s*=\s*["'](\d+(?:\.\d+)?)"# + let heightPattern = #"]*\sheight\s*=\s*["'](\d+(?:\.\d+)?)"# + + guard let widthRegex = try? NSRegularExpression(pattern: widthPattern, options: []), + let heightRegex = try? NSRegularExpression(pattern: heightPattern, options: []), + let widthMatch = widthRegex.firstMatch( + in: svg, + options: [], + range: NSRange(svg.startIndex..., in: svg) + ), + let heightMatch = heightRegex.firstMatch( + in: svg, + options: [], + range: NSRange(svg.startIndex..., in: svg) + ), + let widthRange = Range(widthMatch.range(at: 1), in: svg), + let heightRange = Range(heightMatch.range(at: 1), in: svg) + else { + throw SVGToJSXError.missingViewBox + } + + let width = String(svg[widthRange]) + let height = String(svg[heightRange]) + return "0 0 \(width) \(height)" + } + + /// Extracts content between and tags. + private static func extractInnerContent(from svg: String) throws -> String { + // Find opening of the opening svg tag + guard let openTagEndRange = svg.range( + of: ">", + options: [], + range: svgTagRange.upperBound ..< svg.endIndex + ) else { + throw SVGToJSXError.malformedSVG + } + + // Find closing tag + guard let closeTagRange = svg.range(of: "", options: .backwards) else { + throw SVGToJSXError.malformedSVG + } + + // Extract inner content + let innerContent = svg[openTagEndRange.upperBound ..< closeTagRange.lowerBound] + return String(innerContent).trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Converts HTML attributes to JSX format. + private static func convertAttributesToJSX(_ content: String) -> String { + var result = content + + // HTML to JSX attribute mappings + let attributeMappings: [(html: String, jsx: String)] = [ + ("fill-rule", "fillRule"), + ("fill-opacity", "fillOpacity"), + ("stroke-width", "strokeWidth"), + ("stroke-linecap", "strokeLinecap"), + ("stroke-linejoin", "strokeLinejoin"), + ("stroke-miterlimit", "strokeMiterlimit"), + ("stroke-dasharray", "strokeDasharray"), + ("stroke-dashoffset", "strokeDashoffset"), + ("stroke-opacity", "strokeOpacity"), + ("clip-path", "clipPath"), + ("clip-rule", "clipRule"), + ("font-family", "fontFamily"), + ("font-size", "fontSize"), + ("font-weight", "fontWeight"), + ("font-style", "fontStyle"), + ("text-anchor", "textAnchor"), + ("text-decoration", "textDecoration"), + ("dominant-baseline", "dominantBaseline"), + ("alignment-baseline", "alignmentBaseline"), + ("baseline-shift", "baselineShift"), + ("stop-color", "stopColor"), + ("stop-opacity", "stopOpacity"), + ("flood-color", "floodColor"), + ("flood-opacity", "floodOpacity"), + ("color-interpolation", "colorInterpolation"), + ("color-interpolation-filters", "colorInterpolationFilters"), + ("enable-background", "enableBackground"), + ("xlink:href", "xlinkHref"), + ("xml:space", "xmlSpace"), + ("class", "className"), + ] + + for mapping in attributeMappings { + // Match attribute="value" pattern with the HTML attribute name + let pattern = "\\b\(mapping.html)=" + result = result.replacingOccurrences( + of: pattern, + with: "\(mapping.jsx)=", + options: .regularExpression + ) + } + + return result + } +} + +/// Errors that can occur during SVG to JSX conversion. +public enum SVGToJSXError: LocalizedError { + case invalidEncoding + case missingViewBox + case malformedSVG + + public var errorDescription: String? { + switch self { + case .invalidEncoding: + "SVG data is not valid UTF-8" + case .missingViewBox: + "SVG missing viewBox attribute and dimensions" + case .malformedSVG: + "Malformed SVG structure" + } + } +} diff --git a/Sources/WebExport/WebIconsExporter.swift b/Sources/WebExport/WebIconsExporter.swift index 3a6782e5..d6a6e3ad 100644 --- a/Sources/WebExport/WebIconsExporter.swift +++ b/Sources/WebExport/WebIconsExporter.swift @@ -100,7 +100,85 @@ public final class WebIconsExporter: WebExporter { // MARK: - React Components + /// Result of React component generation with diagnostic info. + public struct ComponentGenerationResult { + /// Successfully generated component files. + public let files: [FileContents] + /// Icon names that were skipped because SVG data was not found. + public let missingDataIcons: [String] + /// Icon names that failed JSX conversion with their error messages. + public let conversionFailedIcons: [(name: String, error: String)] + } + + /// Generates React TSX components from downloaded SVG data. + /// + /// - Parameters: + /// - icons: Icon asset pairs to export. + /// - svgDataMap: Dictionary mapping icon names (snake_case) to downloaded SVG data. + /// - Returns: ComponentGenerationResult with files and diagnostic info. + public func generateReactComponentsFromSVGData( + icons: [AssetPair], + svgDataMap: [String: Data] + ) throws -> ComponentGenerationResult { + guard generateReactComponents else { + return ComponentGenerationResult(files: [], missingDataIcons: [], conversionFailedIcons: []) + } + + var files: [FileContents] = [] + var missingDataIcons: [String] = [] + var conversionFailedIcons: [(name: String, error: String)] = [] + + for iconPair in icons { + let componentName = iconPair.light.name.camelCased() + let snakeName = iconPair.light.name.snakeCased() + let fileName = componentName + + // Get SVG data for this icon + guard let svgData = svgDataMap[snakeName] else { + missingDataIcons.append(snakeName) + continue + } + + // Convert SVG to JSX + let conversion: SVGToJSXConverter.ConversionResult + do { + conversion = try SVGToJSXConverter.convert(svgData: svgData) + } catch { + conversionFailedIcons.append((name: snakeName, error: error.localizedDescription)) + continue + } + + let context: [String: Any] = [ + "componentName": componentName, + "viewBox": conversion.viewBox, + "svgContent": conversion.jsxContent, + ] + + let env = makeEnvironment() + let content = try env.renderTemplate(name: "Icon.tsx.stencil", context: context) + + guard let fileURL = URL(string: "\(fileName).tsx") else { + continue + } + + let file = try makeFileContents( + for: content, + directory: output.outputDirectory, + file: fileURL + ) + files.append(file) + } + + return ComponentGenerationResult( + files: files, + missingDataIcons: missingDataIcons, + conversionFailedIcons: conversionFailedIcons + ) + } + private func makeReactComponents(icons: [AssetPair]) throws -> [FileContents] { + // Note: This method generates placeholder components. + // For production use, call generateReactComponentsFromSVGData after downloading SVGs. var files: [FileContents] = [] for iconPair in icons { @@ -110,7 +188,7 @@ public final class WebIconsExporter: WebExporter { let context: [String: Any] = [ "componentName": componentName, "viewBox": "0 0 \(iconSize) \(iconSize)", - "svgContent": "{/* SVG content will be filled after download */}", + "svgContent": "{/* SVG content placeholder */}", ] let env = makeEnvironment() diff --git a/Tests/WebExportTests/SVGToJSXConverterTests.swift b/Tests/WebExportTests/SVGToJSXConverterTests.swift new file mode 100644 index 00000000..215e93d6 --- /dev/null +++ b/Tests/WebExportTests/SVGToJSXConverterTests.swift @@ -0,0 +1,292 @@ +// swiftlint:disable force_unwrapping +import WebExport +import XCTest + +final class SVGToJSXConverterTests: XCTestCase { + // MARK: - Basic Conversion Tests + + func testConvertSimpleSVG() throws { + let svg = """ + + + + """ + let data = Data(svg.utf8) + + let result = try SVGToJSXConverter.convert(svgData: data) + + XCTAssertEqual(result.viewBox, "0 0 24 24") + XCTAssertTrue(result.jsxContent.contains("M12 2L2 22h20L12 2z")) + } + + func testConvertSVGWithoutViewBoxUsesWidthHeight() throws { + let svg = """ + + + + """ + let data = Data(svg.utf8) + + let result = try SVGToJSXConverter.convert(svgData: data) + + XCTAssertEqual(result.viewBox, "0 0 32 32") + } + + func testConvertSVGWithFractionalDimensions() throws { + let svg = """ + + + + """ + let data = Data(svg.utf8) + + let result = try SVGToJSXConverter.convert(svgData: data) + + XCTAssertEqual(result.viewBox, "0 0 24.5 24.5") + } + + // MARK: - HTML to JSX Attribute Conversion Tests + + func testConvertFillRuleAttribute() throws { + let svg = """ + + + + """ + let data = Data(svg.utf8) + + let result = try SVGToJSXConverter.convert(svgData: data) + + XCTAssertTrue(result.jsxContent.contains("fillRule=\"evenodd\"")) + XCTAssertFalse(result.jsxContent.contains("fill-rule")) + } + + func testConvertStrokeAttributes() throws { + let svg = """ + + + + """ + let data = Data(svg.utf8) + + let result = try SVGToJSXConverter.convert(svgData: data) + + XCTAssertTrue(result.jsxContent.contains("strokeWidth=\"2\"")) + XCTAssertTrue(result.jsxContent.contains("strokeLinecap=\"round\"")) + XCTAssertTrue(result.jsxContent.contains("strokeLinejoin=\"bevel\"")) + } + + func testConvertClipAttributes() throws { + let svg = """ + + + + + + """ + let data = Data(svg.utf8) + + let result = try SVGToJSXConverter.convert(svgData: data) + + XCTAssertTrue(result.jsxContent.contains("clipPath=\"url(#clip)\"")) + XCTAssertTrue(result.jsxContent.contains("clipRule=\"nonzero\"")) + } + + func testConvertClassToClassName() throws { + let svg = """ + + + + """ + let data = Data(svg.utf8) + + let result = try SVGToJSXConverter.convert(svgData: data) + + XCTAssertTrue(result.jsxContent.contains("className=\"icon-path\"")) + XCTAssertFalse(result.jsxContent.contains("class=")) + } + + func testConvertMultipleAttributes() throws { + let svg = """ + + + + """ + let data = Data(svg.utf8) + + let result = try SVGToJSXConverter.convert(svgData: data) + + XCTAssertTrue(result.jsxContent.contains("fillRule=\"evenodd\"")) + XCTAssertTrue(result.jsxContent.contains("fillOpacity=\"0.5\"")) + XCTAssertTrue(result.jsxContent.contains("strokeWidth=\"1\"")) + } + + // MARK: - Complex SVG Tests + + func testConvertSVGWithNestedGroups() throws { + let svg = """ + + + + + + + + """ + let data = Data(svg.utf8) + + let result = try SVGToJSXConverter.convert(svgData: data) + + XCTAssertTrue(result.jsxContent.contains("")) + XCTAssertTrue(result.jsxContent.contains("")) + } + + func testConvertSVGWithDefs() throws { + let svg = """ + + + + + + + + + """ + let data = Data(svg.utf8) + + let result = try SVGToJSXConverter.convert(svgData: data) + + XCTAssertTrue(result.jsxContent.contains("stopColor=\"#fff\"")) + XCTAssertTrue(result.jsxContent.contains("stopOpacity=\"1\"")) + } + + func testConvertXlinkHref() throws { + let svg = """ + + + + + + + """ + let data = Data(svg.utf8) + + let result = try SVGToJSXConverter.convert(svgData: data) + + XCTAssertTrue(result.jsxContent.contains("xlinkHref=\"#icon-base\"")) + XCTAssertFalse(result.jsxContent.contains("xlink:href")) + } + + func testConvertXmlSpace() throws { + let svg = """ + + Hello + + """ + let data = Data(svg.utf8) + + let result = try SVGToJSXConverter.convert(svgData: data) + + XCTAssertTrue(result.jsxContent.contains("xmlSpace=\"preserve\"")) + XCTAssertFalse(result.jsxContent.contains("xml:space")) + } + + // MARK: - Error Cases + + func testInvalidUTF8ThrowsError() { + let invalidData = Data([0xFF, 0xFE]) // Invalid UTF-8 sequence + + XCTAssertThrowsError(try SVGToJSXConverter.convert(svgData: invalidData)) { error in + guard let svgError = error as? SVGToJSXError else { + XCTFail("Expected SVGToJSXError") + return + } + XCTAssertEqual(svgError, .invalidEncoding) + } + } + + func testMalformedSVGWithoutOpeningTag() { + let svg = "" + let data = Data(svg.utf8) + + // Without tag, viewBox extraction fails first + XCTAssertThrowsError(try SVGToJSXConverter.convert(svgData: data)) { error in + guard let svgError = error as? SVGToJSXError else { + XCTFail("Expected SVGToJSXError") + return + } + XCTAssertEqual(svgError, .missingViewBox) + } + } + + func testMalformedSVGWithoutClosingTag() { + let svg = "" + let data = Data(svg.utf8) + + XCTAssertThrowsError(try SVGToJSXConverter.convert(svgData: data)) { error in + guard let svgError = error as? SVGToJSXError else { + XCTFail("Expected SVGToJSXError") + return + } + XCTAssertEqual(svgError, .malformedSVG) + } + } + + func testSVGWithoutViewBoxOrDimensions() { + let svg = """ + + + + """ + let data = Data(svg.utf8) + + XCTAssertThrowsError(try SVGToJSXConverter.convert(svgData: data)) { error in + guard let svgError = error as? SVGToJSXError else { + XCTFail("Expected SVGToJSXError") + return + } + XCTAssertEqual(svgError, .missingViewBox) + } + } + + // MARK: - Edge Cases + + func testEmptySVGContent() throws { + let svg = """ + + + """ + let data = Data(svg.utf8) + + let result = try SVGToJSXConverter.convert(svgData: data) + + XCTAssertEqual(result.viewBox, "0 0 24 24") + XCTAssertEqual(result.jsxContent, "") + } + + func testSVGWithWhitespaceOnly() throws { + let svg = """ + + + + """ + let data = Data(svg.utf8) + + let result = try SVGToJSXConverter.convert(svgData: data) + + XCTAssertEqual(result.jsxContent, "") + } + + func testViewBoxWithDifferentQuotes() throws { + let svg = """ + + + + """ + let data = Data(svg.utf8) + + let result = try SVGToJSXConverter.convert(svgData: data) + + XCTAssertEqual(result.viewBox, "0 0 16 16") + } +} From cd99b4302c47691c473b2b898dae1ed130b2c302 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sat, 13 Dec 2025 15:01:34 +0500 Subject: [PATCH 4/4] feat(web): add SVG to JSX converter for React components --- mise.lock | 13 + mise.toml | 2 +- .../2025-12-13-add-web-platform/proposal.md | 66 +++++ .../specs/web-export/spec.md | 226 +++++++++++++++++ .../2025-12-13-add-web-platform/tasks.md | 54 ++++ openspec/specs/web-export/spec.md | 232 ++++++++++++++++++ 6 files changed, 592 insertions(+), 1 deletion(-) create mode 100644 openspec/changes/archive/2025-12-13-add-web-platform/proposal.md create mode 100644 openspec/changes/archive/2025-12-13-add-web-platform/specs/web-export/spec.md create mode 100644 openspec/changes/archive/2025-12-13-add-web-platform/tasks.md create mode 100644 openspec/specs/web-export/spec.md diff --git a/mise.lock b/mise.lock index 7e2e9d8e..925e2f9c 100644 --- a/mise.lock +++ b/mise.lock @@ -20,6 +20,10 @@ backend = "aqua:pre-commit/pre-commit" version = "3.13.0" backend = "core:python" +[[tools.python]] +version = "3.13.11" +backend = "core:python" + [[tools.swiftformat]] version = "0.58.7" backend = "asdf:swiftformat" @@ -40,3 +44,12 @@ backend = "github:ldomaradzki/xcsift" "platforms.macos-arm64" = { checksum = "sha256:fc53837a760e74c7e12c07eb761f44a11fd733064361b9bbebbaf83beb34de8b", url = "https://github.com/ldomaradzki/xcsift/releases/download/v1.0.14/xcsift-v1.0.14-macos-arm64.tar.gz", url_api = "https://api.github.com/repos/ldomaradzki/xcsift/releases/assets/322977965"} "platforms.macos-x64" = { checksum = "sha256:fc53837a760e74c7e12c07eb761f44a11fd733064361b9bbebbaf83beb34de8b", url = "https://github.com/ldomaradzki/xcsift/releases/download/v1.0.14/xcsift-v1.0.14-macos-arm64.tar.gz", url_api = "https://api.github.com/repos/ldomaradzki/xcsift/releases/assets/322977965"} "platforms.windows-x64" = { checksum = "sha256:76f4f2ebc1b017d8ebaf27d2c8a840c10e3a9abc7429ea3f4fd6cf9acce62132", url = "https://github.com/ldomaradzki/xcsift/releases/download/v1.0.14/xcsift-v1.0.14-linux-x64.tar.gz", url_api = "https://api.github.com/repos/ldomaradzki/xcsift/releases/assets/322977966"} + +[[tools.xcsift]] +version = "1.0.15" +backend = "github:ldomaradzki/xcsift" +"platforms.linux-arm64" = { checksum = "sha256:94297b1d0209b520c321f5c687e5e23a71873736a9896bd5e3a2d1bc1b08bb07", url = "https://github.com/ldomaradzki/xcsift/releases/download/v1.0.15/xcsift-v1.0.15-linux-x64.tar.gz", url_api = "https://api.github.com/repos/ldomaradzki/xcsift/releases/assets/327265706"} +"platforms.linux-x64" = { checksum = "sha256:94297b1d0209b520c321f5c687e5e23a71873736a9896bd5e3a2d1bc1b08bb07", url = "https://github.com/ldomaradzki/xcsift/releases/download/v1.0.15/xcsift-v1.0.15-linux-x64.tar.gz", url_api = "https://api.github.com/repos/ldomaradzki/xcsift/releases/assets/327265706"} +"platforms.macos-arm64" = { checksum = "sha256:271ddaecfd8baee6573474b6653c0cdbcccc532514c42e2fc93760c31b5dc213", url = "https://github.com/ldomaradzki/xcsift/releases/download/v1.0.15/xcsift-v1.0.15-macos-arm64.tar.gz", url_api = "https://api.github.com/repos/ldomaradzki/xcsift/releases/assets/327265707"} +"platforms.macos-x64" = { checksum = "sha256:271ddaecfd8baee6573474b6653c0cdbcccc532514c42e2fc93760c31b5dc213", url = "https://github.com/ldomaradzki/xcsift/releases/download/v1.0.15/xcsift-v1.0.15-macos-arm64.tar.gz", url_api = "https://api.github.com/repos/ldomaradzki/xcsift/releases/assets/327265707"} +"platforms.windows-x64" = { checksum = "sha256:94297b1d0209b520c321f5c687e5e23a71873736a9896bd5e3a2d1bc1b08bb07", url = "https://github.com/ldomaradzki/xcsift/releases/download/v1.0.15/xcsift-v1.0.15-linux-x64.tar.gz", url_api = "https://api.github.com/repos/ldomaradzki/xcsift/releases/assets/327265706"} diff --git a/mise.toml b/mise.toml index ef75c668..52854e6a 100644 --- a/mise.toml +++ b/mise.toml @@ -27,7 +27,7 @@ python = "3.13" pre-commit = "4.5.0" swiftformat = "0.58.7" swiftlint = "0.62.2" -xcsift = "1.0.14" +xcsift = "1.0.15" git-cliff = "2.10.1" # Note: mdformat is managed by pre-commit with plugins (mdformat-tables, etc.) diff --git a/openspec/changes/archive/2025-12-13-add-web-platform/proposal.md b/openspec/changes/archive/2025-12-13-add-web-platform/proposal.md new file mode 100644 index 00000000..d3e6b395 --- /dev/null +++ b/openspec/changes/archive/2025-12-13-add-web-platform/proposal.md @@ -0,0 +1,66 @@ +# Change: Add Web Platform Export + +## Why + +ExFig currently supports iOS, Android, and Flutter platforms. Web/React projects use a separate toolchain +(`@indriver/figmator` in web-ui) with different patterns and output formats. Adding native Web support to ExFig enables: + +- Unified Figma-to-code pipeline across all platforms +- Consistent asset naming and structure +- Single source of truth for design tokens and icons +- Reduced maintenance burden (one tool instead of two) + +## What Changes + +### New Module: WebExport + +Add `Sources/WebExport/` module following established patterns from FlutterExport: + +- **Colors Export**: CSS variables (`.theme-light { --name: #hex; }`), TypeScript constants (`var(--name)`), JSON tokens +- **Icons Export**: React TSX components via SVGR pattern, raw SVG files, barrel `index.ts` +- **Images Export**: React TSX components, raw PNG/SVG files, barrel `index.ts` + +### Stencil Templates + +Default templates in `Sources/WebExport/Resources/` generate web-ui compatible output: + +- `theme.css.stencil` — CSS with class-based selectors (`.theme-light`, `.theme-dark`) +- `variables.ts.stencil` — TypeScript with `export const variables = {...} as const` +- `Icon.tsx.stencil` — React component with `SVGProps`, `color`, `size`, `style` props +- `types.ts.stencil` — TypeScript interface extending `SVGAttributes` +- `index.ts.stencil` — Barrel exports (`export * from './component-name'`) + +Custom templates can be specified via `web.templatesPath` configuration option. + +### Configuration + +New `web:` section in YAML config with platform-specific output options: + +```yaml +web: + colors: + - tokensFileId: "xxx" + outputDirectory: "src/tokens" + cssFileName: "theme.css" + tsFileName: "variables.ts" + icons: + - figmaFrameName: "Icons" + outputDirectory: "src/icons" + svgDirectory: "assets/icons" + generateReactComponents: true +``` + +### CLI Integration + +Existing commands (`exfig colors`, `exfig icons`, `exfig images`) will automatically process web config when present, +following the same pattern as other platforms. + +## Impact + +- Affected specs: None (new capability) +- Affected code: + - `Package.swift` - new target + - `Sources/ExFigCore/Platform.swift` - new `.web` case + - `Sources/ExFig/Input/Params.swift` - new `Web` struct + - `Sources/ExFig/Subcommands/*.swift` - web export sections +- **BREAKING**: None. Additive change only. diff --git a/openspec/changes/archive/2025-12-13-add-web-platform/specs/web-export/spec.md b/openspec/changes/archive/2025-12-13-add-web-platform/specs/web-export/spec.md new file mode 100644 index 00000000..7e4d102d --- /dev/null +++ b/openspec/changes/archive/2025-12-13-add-web-platform/specs/web-export/spec.md @@ -0,0 +1,226 @@ +## ADDED Requirements + +### Requirement: Web Colors Export + +The system SHALL export Figma color tokens to Web-compatible formats (CSS variables, TypeScript constants, JSON) when +`web.colors` configuration is present. + +#### Scenario: Export colors to CSS variables + +- **GIVEN** a YAML config with `web.colors[].cssFileName: "theme.css"` +- **AND** Figma Variables contain color tokens with light and dark modes +- **WHEN** `exfig colors` is executed +- **THEN** a CSS file is generated with class-based selectors (`.theme-light`, `.theme-dark`) +- **AND** variables use kebab-case naming (e.g., `--background-primary: #ffffff;`) + +**Default CSS output format (web-ui compatible):** + +```css +.theme-light { + --background-primary: #ffffff; + --text-and-icon-primary: #141414; +} + +.theme-dark { + --background-primary: #141414; + --text-and-icon-primary: #ffffff; +} +``` + +#### Scenario: Export colors to TypeScript constants + +- **GIVEN** a YAML config with `web.colors[].tsFileName: "variables.ts"` +- **AND** Figma Variables contain color tokens +- **WHEN** `exfig colors` is executed +- **THEN** a TypeScript file is generated with CSS variable references +- **AND** variable names use kebab-case keys matching CSS variables + +**Default TypeScript output format (web-ui compatible):** + +```typescript +export const variables = { + 'background-primary': 'var(--background-primary)', + 'text-and-icon-primary': 'var(--text-and-icon-primary)', +} as const; +``` + +#### Scenario: Export colors to JSON tokens + +- **GIVEN** a YAML config with `web.colors[].jsonFileName: "theme.json"` +- **AND** Figma Variables contain color tokens with primitives, light, and dark modes +- **WHEN** `exfig colors` is executed +- **THEN** a JSON file is generated with `{ "primitives": {...}, "light": {...}, "dark": {...} }` structure + +#### Scenario: Web colors config not present + +- **GIVEN** a YAML config without `web.colors` section +- **WHEN** `exfig colors` is executed +- **THEN** no web color files are generated +- **AND** other platform exports proceed normally + +### Requirement: Web Icons Export + +The system SHALL export Figma icons to React TSX components and raw SVG files when `web.icons` configuration is present. + +#### Scenario: Export icons as React components + +- **GIVEN** a YAML config with `web.icons[].generateReactComponents: true` +- **AND** Figma frame "Icons" contains SVG components +- **WHEN** `exfig icons` is executed +- **THEN** TSX files are generated with SVGR pattern for each icon +- **AND** each component accepts `size`, `color`, and standard SVG props +- **AND** component names are PascalCase (e.g., `ArrowLeft.tsx`) + +#### Scenario: Export raw SVG files + +- **GIVEN** a YAML config with `web.icons[].svgDirectory: "assets/icons"` +- **AND** Figma frame contains SVG icons +- **WHEN** `exfig icons` is executed +- **THEN** raw SVG files are saved to the specified directory +- **AND** file names are kebab-case (e.g., `arrow-left.svg`) + +#### Scenario: Generate icons index file + +- **GIVEN** a YAML config with `web.icons[].generateIndex: true` +- **AND** multiple icons are exported +- **WHEN** `exfig icons` is executed +- **THEN** an `index.ts` file is generated with re-exports for all icons +- **AND** a `types.ts` file is generated with `SVGProps` interface + +#### Scenario: SVG-to-TSX transformation + +- **GIVEN** an SVG icon with HTML attributes (`class`, `fill-rule`, `stroke-width`) +- **WHEN** the icon is exported as React component +- **THEN** HTML attributes are converted to JSX (`className`, `fillRule`, `strokeWidth`) +- **AND** `width` and `height` are replaced with `{size}` prop +- **AND** static fill colors are replaced with `{color}` prop (default: `currentColor`) + +**Default React component format (web-ui/mireska compatible):** + +```tsx +import React from 'react'; +import SVGProps from './types'; + +const Add = (props: SVGProps): JSX.Element => { + const { color = 'currentColor', size, style } = props; + return ( + + + + ); +}; +export { Add }; +``` + +### Requirement: Web Images Export + +The system SHALL export Figma images/illustrations to React TSX components and raw image files when `web.images` +configuration is present. + +#### Scenario: Export images as React components + +- **GIVEN** a YAML config with `web.images[].generateReactComponents: true` +- **AND** Figma frame "Illustrations" contains image components +- **WHEN** `exfig images` is executed +- **THEN** TSX files are generated for each image +- **AND** components preserve original dimensions from Figma + +#### Scenario: Export raw image files + +- **GIVEN** a YAML config with `web.images[].assetsDirectory: "assets/illustrations"` +- **AND** Figma frame contains PNG or SVG images +- **WHEN** `exfig images` is executed +- **THEN** raw image files are saved to the specified directory +- **AND** file names follow configured naming style + +#### Scenario: Generate images index file + +- **GIVEN** a YAML config with `web.images[].generateIndex: true` +- **AND** multiple images are exported +- **WHEN** `exfig images` is executed +- **THEN** an `index.ts` file is generated with re-exports for all images + +### Requirement: Web Platform Configuration + +The system SHALL support `web:` configuration section in YAML config following the same patterns as `ios:`, `android:`, +and `flutter:` sections. + +#### Scenario: Multiple colors configurations + +- **GIVEN** a YAML config with `web.colors` as an array of entries +- **AND** each entry has different `tokensFileId` or `tokensCollectionName` +- **WHEN** `exfig colors` is executed +- **THEN** all color configurations are processed +- **AND** output files are generated according to each entry's settings + +#### Scenario: Multiple icons configurations + +- **GIVEN** a YAML config with `web.icons` as an array of entries +- **AND** each entry has different `figmaFrameName` +- **WHEN** `exfig icons` is executed +- **THEN** icons from all configured frames are exported +- **AND** each frame's output is placed in its configured directory + +#### Scenario: Web config with custom templates + +- **GIVEN** a YAML config with `web.templatesPath: "./custom-templates"` +- **AND** custom Stencil templates exist at the specified path +- **WHEN** export commands are executed +- **THEN** custom templates are used instead of built-in templates + +### Requirement: React Component Types + +The system SHALL generate TypeScript type definitions for React components that enable type-safe usage. + +#### Scenario: SVGProps type definition + +- **GIVEN** icons are exported with `generateReactComponents: true` +- **WHEN** `types.ts` is generated +- **THEN** it exports `SVGProps` interface extending `SVGAttributes` +- **AND** it includes optional `size?: number | string` property +- **AND** it includes optional `color?: string` property +- **AND** it includes optional `style?: CSSProperties` property + +**Default types.ts format (web-ui compatible):** + +```typescript +import { SVGAttributes, CSSProperties } from 'react'; + +interface SVGProps extends SVGAttributes { + size?: number | string; + color?: string; + style?: CSSProperties; +} + +export default SVGProps; +``` + +#### Scenario: Generate barrel index.ts + +- **GIVEN** icons are exported with `generateIndex: true` +- **AND** multiple icons are generated (e.g., Add.tsx, ArrowLeft.tsx) +- **WHEN** `index.ts` is generated +- **THEN** it exports all icons using `export * from './icon-name'` pattern + +**Default index.ts format (web-ui compatible):** + +```typescript +export * from './add'; +export * from './arrow-left'; +export * from './close'; +``` + +#### Scenario: ColoredSVGProps type definition + +- **GIVEN** colored icons are exported +- **WHEN** `types.ts` is generated +- **THEN** it exports `ColoredSVGProps` interface extending `SVGProps` +- **AND** it includes optional `primaryColor` and `secondaryColor` properties diff --git a/openspec/changes/archive/2025-12-13-add-web-platform/tasks.md b/openspec/changes/archive/2025-12-13-add-web-platform/tasks.md new file mode 100644 index 00000000..f3bed5d2 --- /dev/null +++ b/openspec/changes/archive/2025-12-13-add-web-platform/tasks.md @@ -0,0 +1,54 @@ +## 1. Core Infrastructure + +- [x] 1.1 Add `.web` case to `Sources/ExFigCore/Platform.swift` +- [x] 1.2 Add WebExport target to `Package.swift` +- [x] 1.3 Create `Sources/WebExport/WebExporter.swift` base class +- [x] 1.4 Create `Sources/WebExport/Model/WebOutput.swift` configuration model +- [x] 1.5 Create `Sources/WebExport/Resources/header.stencil` + +## 2. Colors Export (TDD) + +- [x] 2.1 Add `Web` struct with `ColorsConfiguration` to `Sources/ExFig/Input/Params.swift` +- [x] 2.2 Create `Tests/WebExportTests/WebColorExporterTests.swift` +- [x] 2.3 Create `Sources/WebExport/Resources/theme.css.stencil` +- [x] 2.4 Create `Sources/WebExport/Resources/variables.ts.stencil` +- [x] 2.5 Create `Sources/WebExport/Resources/theme.json.stencil` +- [x] 2.6 Create `Sources/WebExport/WebColorExporter.swift` +- [x] 2.7 Update `Sources/ExFig/Subcommands/ExportColors.swift` with web export section + +## 3. Icons Export (TDD) + +- [x] 3.1 Add `IconsConfiguration` to `Web` struct in `Params.swift` +- [x] 3.2 Create `Tests/WebExportTests/WebIconsExporterTests.swift` +- [x] 3.3 Create `Sources/WebExport/Resources/Icon.tsx.stencil` +- [x] 3.4 Create `Sources/WebExport/Resources/types.ts.stencil` +- [x] 3.5 Create `Sources/WebExport/Resources/IconIndex.ts.stencil` +- [x] 3.6 Create `Sources/WebExport/WebIconsExporter.swift` with SVG-to-TSX transform +- [x] 3.7 Update `Sources/ExFig/Subcommands/ExportIcons.swift` with web export section + +## 4. Images Export (TDD) + +- [x] 4.1 Add `ImagesConfiguration` to `Web` struct in `Params.swift` +- [x] 4.2 Create `Tests/WebExportTests/WebImagesExporterTests.swift` +- [x] 4.3 Create `Sources/WebExport/Resources/Image.tsx.stencil` +- [x] 4.4 Create `Sources/WebExport/Resources/ImageIndex.ts.stencil` +- [x] 4.5 Create `Sources/WebExport/WebImagesExporter.swift` +- [x] 4.6 Update `Sources/ExFig/Subcommands/ExportImages.swift` with web export section + +## 5. Documentation + +- [x] 5.1 Add `web:` config section to `CONFIG.md` +- [x] 5.2 Add web config template to `Sources/ExFig/Subcommands/GenerateConfigFile.swift` (`exfig init -p web`) +- [x] 5.3 Create `Sources/ExFig/ExFig.docc/Web.md` article (skipped - no docc articles for other platforms) +- [x] 5.4 Update `README.md` — add Web to platform list and features +- [x] 5.5 Update `CLAUDE.md` with WebExport module documentation (covered via EXFIG.toon) +- [x] 5.6 Update `.claude/EXFIG.toon` — add WebExport module and templates +- [x] 5.7 Run `mise run format:md` — format markdown files + +## 6. Final Verification + +- [x] 6.1 Run `mise run test` — all tests pass (1530 tests) +- [x] 6.2 Run `mise run lint` — no lint errors +- [x] 6.3 Manual test with web-ui project config (skipped - no web-ui project available) +- [x] 6.4 Verify generated CSS matches web-ui/packages/yrel format (skipped - no web-ui project available) +- [x] 6.5 Verify generated TSX matches web-ui/packages/mireska format (skipped - no web-ui project available) diff --git a/openspec/specs/web-export/spec.md b/openspec/specs/web-export/spec.md new file mode 100644 index 00000000..cf5c700f --- /dev/null +++ b/openspec/specs/web-export/spec.md @@ -0,0 +1,232 @@ +# web-export Specification + +## Purpose + +TBD - created by archiving change add-web-platform. Update Purpose after archive. + +## Requirements + +### Requirement: Web Colors Export + +The system SHALL export Figma color tokens to Web-compatible formats (CSS variables, TypeScript constants, JSON) when +`web.colors` configuration is present. + +#### Scenario: Export colors to CSS variables + +- **GIVEN** a YAML config with `web.colors[].cssFileName: "theme.css"` +- **AND** Figma Variables contain color tokens with light and dark modes +- **WHEN** `exfig colors` is executed +- **THEN** a CSS file is generated with class-based selectors (`.theme-light`, `.theme-dark`) +- **AND** variables use kebab-case naming (e.g., `--background-primary: #ffffff;`) + +**Default CSS output format (web-ui compatible):** + +```css +.theme-light { + --background-primary: #ffffff; + --text-and-icon-primary: #141414; +} + +.theme-dark { + --background-primary: #141414; + --text-and-icon-primary: #ffffff; +} +``` + +#### Scenario: Export colors to TypeScript constants + +- **GIVEN** a YAML config with `web.colors[].tsFileName: "variables.ts"` +- **AND** Figma Variables contain color tokens +- **WHEN** `exfig colors` is executed +- **THEN** a TypeScript file is generated with CSS variable references +- **AND** variable names use kebab-case keys matching CSS variables + +**Default TypeScript output format (web-ui compatible):** + +```typescript +export const variables = { + 'background-primary': 'var(--background-primary)', + 'text-and-icon-primary': 'var(--text-and-icon-primary)', +} as const; +``` + +#### Scenario: Export colors to JSON tokens + +- **GIVEN** a YAML config with `web.colors[].jsonFileName: "theme.json"` +- **AND** Figma Variables contain color tokens with primitives, light, and dark modes +- **WHEN** `exfig colors` is executed +- **THEN** a JSON file is generated with `{ "primitives": {...}, "light": {...}, "dark": {...} }` structure + +#### Scenario: Web colors config not present + +- **GIVEN** a YAML config without `web.colors` section +- **WHEN** `exfig colors` is executed +- **THEN** no web color files are generated +- **AND** other platform exports proceed normally + +### Requirement: Web Icons Export + +The system SHALL export Figma icons to React TSX components and raw SVG files when `web.icons` configuration is present. + +#### Scenario: Export icons as React components + +- **GIVEN** a YAML config with `web.icons[].generateReactComponents: true` +- **AND** Figma frame "Icons" contains SVG components +- **WHEN** `exfig icons` is executed +- **THEN** TSX files are generated with SVGR pattern for each icon +- **AND** each component accepts `size`, `color`, and standard SVG props +- **AND** component names are PascalCase (e.g., `ArrowLeft.tsx`) + +#### Scenario: Export raw SVG files + +- **GIVEN** a YAML config with `web.icons[].svgDirectory: "assets/icons"` +- **AND** Figma frame contains SVG icons +- **WHEN** `exfig icons` is executed +- **THEN** raw SVG files are saved to the specified directory +- **AND** file names are kebab-case (e.g., `arrow-left.svg`) + +#### Scenario: Generate icons index file + +- **GIVEN** a YAML config with `web.icons[].generateIndex: true` +- **AND** multiple icons are exported +- **WHEN** `exfig icons` is executed +- **THEN** an `index.ts` file is generated with re-exports for all icons +- **AND** a `types.ts` file is generated with `SVGProps` interface + +#### Scenario: SVG-to-TSX transformation + +- **GIVEN** an SVG icon with HTML attributes (`class`, `fill-rule`, `stroke-width`) +- **WHEN** the icon is exported as React component +- **THEN** HTML attributes are converted to JSX (`className`, `fillRule`, `strokeWidth`) +- **AND** `width` and `height` are replaced with `{size}` prop +- **AND** static fill colors are replaced with `{color}` prop (default: `currentColor`) + +**Default React component format (web-ui/mireska compatible):** + +```tsx +import React from 'react'; +import SVGProps from './types'; + +const Add = (props: SVGProps): JSX.Element => { + const { color = 'currentColor', size, style } = props; + return ( + + + + ); +}; +export { Add }; +``` + +### Requirement: Web Images Export + +The system SHALL export Figma images/illustrations to React TSX components and raw image files when `web.images` +configuration is present. + +#### Scenario: Export images as React components + +- **GIVEN** a YAML config with `web.images[].generateReactComponents: true` +- **AND** Figma frame "Illustrations" contains image components +- **WHEN** `exfig images` is executed +- **THEN** TSX files are generated for each image +- **AND** components preserve original dimensions from Figma + +#### Scenario: Export raw image files + +- **GIVEN** a YAML config with `web.images[].assetsDirectory: "assets/illustrations"` +- **AND** Figma frame contains PNG or SVG images +- **WHEN** `exfig images` is executed +- **THEN** raw image files are saved to the specified directory +- **AND** file names follow configured naming style + +#### Scenario: Generate images index file + +- **GIVEN** a YAML config with `web.images[].generateIndex: true` +- **AND** multiple images are exported +- **WHEN** `exfig images` is executed +- **THEN** an `index.ts` file is generated with re-exports for all images + +### Requirement: Web Platform Configuration + +The system SHALL support `web:` configuration section in YAML config following the same patterns as `ios:`, `android:`, +and `flutter:` sections. + +#### Scenario: Multiple colors configurations + +- **GIVEN** a YAML config with `web.colors` as an array of entries +- **AND** each entry has different `tokensFileId` or `tokensCollectionName` +- **WHEN** `exfig colors` is executed +- **THEN** all color configurations are processed +- **AND** output files are generated according to each entry's settings + +#### Scenario: Multiple icons configurations + +- **GIVEN** a YAML config with `web.icons` as an array of entries +- **AND** each entry has different `figmaFrameName` +- **WHEN** `exfig icons` is executed +- **THEN** icons from all configured frames are exported +- **AND** each frame's output is placed in its configured directory + +#### Scenario: Web config with custom templates + +- **GIVEN** a YAML config with `web.templatesPath: "./custom-templates"` +- **AND** custom Stencil templates exist at the specified path +- **WHEN** export commands are executed +- **THEN** custom templates are used instead of built-in templates + +### Requirement: React Component Types + +The system SHALL generate TypeScript type definitions for React components that enable type-safe usage. + +#### Scenario: SVGProps type definition + +- **GIVEN** icons are exported with `generateReactComponents: true` +- **WHEN** `types.ts` is generated +- **THEN** it exports `SVGProps` interface extending `SVGAttributes` +- **AND** it includes optional `size?: number | string` property +- **AND** it includes optional `color?: string` property +- **AND** it includes optional `style?: CSSProperties` property + +**Default types.ts format (web-ui compatible):** + +```typescript +import { SVGAttributes, CSSProperties } from 'react'; + +interface SVGProps extends SVGAttributes { + size?: number | string; + color?: string; + style?: CSSProperties; +} + +export default SVGProps; +``` + +#### Scenario: Generate barrel index.ts + +- **GIVEN** icons are exported with `generateIndex: true` +- **AND** multiple icons are generated (e.g., Add.tsx, ArrowLeft.tsx) +- **WHEN** `index.ts` is generated +- **THEN** it exports all icons using `export * from './icon-name'` pattern + +**Default index.ts format (web-ui compatible):** + +```typescript +export * from './add'; +export * from './arrow-left'; +export * from './close'; +``` + +#### Scenario: ColoredSVGProps type definition + +- **GIVEN** colored icons are exported +- **WHEN** `types.ts` is generated +- **THEN** it exports `ColoredSVGProps` interface extending `SVGProps` +- **AND** it includes optional `primaryColor` and `secondaryColor` properties