├── .gitignore ├── ComponentInspectorBanner.png ├── ComponentInspectorIcon.png ├── README.md ├── manifest.json ├── package-lock.json ├── package.json ├── plugin-src ├── adapter.ts ├── code.ts ├── config.ts ├── formatAngular.ts ├── formatJSON.ts ├── formatReact.ts ├── formatShared.ts ├── formatVue.ts ├── formatWebComponents.ts ├── tsconfig.json ├── types.ts └── utils.ts ├── shared.ts ├── ui-src ├── App.css ├── App.tsx ├── index.d.ts ├── index.html ├── main.tsx ├── parser-html-custom.ts ├── tsconfig.json └── vite-env.d.ts └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /ComponentInspectorBanner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jake-figma/component-inspector/439ff33af7ac921b9adf8fe5b90b09320f66ded6/ComponentInspectorBanner.png -------------------------------------------------------------------------------- /ComponentInspectorIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jake-figma/component-inspector/439ff33af7ac921b9adf8fe5b90b09320f66ded6/ComponentInspectorIcon.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](ComponentInspectorBanner.png) 2 | 3 | # Component Inspector 4 | 5 | A [Figma plugin](https://www.figma.com/community/plugin/1162860898904210114) for inspecting Figma components. 6 | 7 | This plugin provides a look at Figma component properties similar to how they are described in code. This plugin does _not_ generate style code. It generates code that describes component properties. 8 | 9 | Currently supporting instance and component code generation for: 10 | 11 | - React function components 12 | - Angular components 13 | - Vue components (both option and composition APIs) 14 | - Web components 15 | 16 | As well as: 17 | 18 | - JSON 19 | 20 | Would love to hear from you about what works and what doesn't. 21 | 22 | ## Conventions / Pro Tips 23 | 24 | ### Slots 25 | 26 | Currently, this plugin supports tag-named slots. Add the `--SLOT[tagname]` suffix to the name of a text component property in Figma and it will generate a slot for that attribute. For example, if you create a component with a text property named `"heading--SLOT[h2]"`, it would generate the following React instance and definition code: 27 | 28 | ```tsx 29 | My slot content} /> 30 | ``` 31 | 32 | ```tsx 33 | const Component: FC<{ 34 | heading: ReactNode; 35 | }> = ({ heading }) => <>{heading}; 36 | ``` 37 | 38 | The tagname will default to `span` if you use the suffix `--SLOT` without a tagname. 39 | 40 | If your Figma component has a single text property, it will be treated as a generic slot. 41 | 42 | > This can be configured by running the Component Inspector > Configuration command. 43 | 44 | ### Boolean visibility 45 | 46 | If you have a boolean Figma component property that controls visibility of a text or instance swap property (or one of their ancestors), that boolean property will be ignored in generated code and the text or instance swap property will disappear when the boolean is false. 47 | 48 | ### `"undefined"` variant options and instance swaps 49 | 50 | If you have a variant option property that defaults to the string `"undefined"`, that property will be treated as truly optional (no default). 51 | 52 | If you have an instance swap property that defaults to a component named `"undefined"`, that property will be treated as truly optional (no default). 53 | 54 | > This can be configured by running the Component Inspector > Configuration command. 55 | 56 | ### Ignored property prefix 57 | 58 | You can configure the component inspector to ignore properties named with a provided prefix. 59 | 60 | > This can be configured by running the Component Inspector > Configuration command. 61 | 62 | ### Numeric variant options 63 | 64 | If your variant options are all numeric or if the default value for a text property is numeric, the generated code will treat it like a number type property. 65 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Component Inspector", 3 | "id": "1162860898904210114", 4 | "api": "1.0.0", 5 | "editorType": ["dev", "figma"], 6 | "capabilities": ["codegen"], 7 | "permissions": [], 8 | "main": "dist/code.js", 9 | "ui": "dist/index.html", 10 | "networkAccess": { 11 | "allowedDomains": ["none"] 12 | }, 13 | "codegenLanguages": [ 14 | { "label": "Angular", "value": "angular" }, 15 | { "label": "React", "value": "react" }, 16 | { "label": "Vue: Composition API", "value": "vue-composition" }, 17 | { "label": "Vue: Options API", "value": "vue-options" }, 18 | { "label": "Web Components", "value": "web" }, 19 | { "label": "JSON", "value": "json" } 20 | ], 21 | "codegenPreferences": [ 22 | { 23 | "itemType": "select", 24 | "propertyName": "boolean", 25 | "label": "Boolean properties on instances", 26 | "options": [ 27 | { "label": "Implicit", "value": "implicit", "isDefault": true }, 28 | { "label": "Explicit", "value": "explicit" } 29 | ] 30 | }, 31 | { 32 | "itemType": "select", 33 | "propertyName": "comments", 34 | "label": "Comment generation in definitions", 35 | "options": [ 36 | { "label": "Disabled", "value": "disabled", "isDefault": true }, 37 | { "label": "Enabled", "value": "enabled" } 38 | ] 39 | }, 40 | { 41 | "itemType": "select", 42 | "propertyName": "defaults", 43 | "label": "Default values on instances", 44 | "options": [ 45 | { "label": "Shown", "value": "shown", "isDefault": true }, 46 | { "label": "Hidden", "value": "hidden" } 47 | ] 48 | }, 49 | { 50 | "itemType": "action", 51 | "propertyName": "settings", 52 | "label": "More Settings" 53 | } 54 | ], 55 | "menu": [ 56 | { "command": "all", "name": "All" }, 57 | { "separator": true }, 58 | { "command": "angular", "name": "Angular" }, 59 | { "command": "react", "name": "React" }, 60 | { "command": "vue", "name": "Vue" }, 61 | { "command": "web", "name": "Web Components" }, 62 | { "separator": true }, 63 | { "command": "json", "name": "JSON" }, 64 | { "separator": true }, 65 | { "command": "config", "name": "Configuration" } 66 | ] 67 | } 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react", 3 | "version": "1.0.0", 4 | "description": "react", 5 | "scripts": { 6 | "test": "npm run tsc && npm run build", 7 | "format": "prettier --write .", 8 | "tsc": "npm run tsc:main && npm run tsc:ui", 9 | "tsc:main": "tsc --noEmit -p plugin-src", 10 | "tsc:ui": "tsc --noEmit -p ui-src", 11 | "tsc:watch": "concurrently -n widget,iframe \"npm run tsc:main -- --watch --preserveWatchOutput\" \"npm run tsc:ui -- --watch --preserveWatchOutput\"", 12 | "build": "npm run build:ui && npm run build:main -- --minify", 13 | "build:main": "esbuild plugin-src/code.ts --bundle --outfile=dist/code.js", 14 | "build:ui": "npx vite build --minify esbuild --emptyOutDir=false", 15 | "build:watch": "concurrently -n widget,iframe \"npm run build:main -- --watch\" \"npm run build:ui -- --watch\"", 16 | "dev": "concurrently -n tsc,build,vite 'npm:tsc:watch' 'npm:build:watch' 'vite'" 17 | }, 18 | "author": "Figma", 19 | "license": "MIT License", 20 | "dependencies": { 21 | "react": "^17.0.0", 22 | "react-dom": "^17.0.0", 23 | "react-syntax-highlighter": "^15.5.0" 24 | }, 25 | "devDependencies": { 26 | "@figma/plugin-typings": "^1.76.0", 27 | "@types/prettier": "^2.7.0", 28 | "@types/react": "^17.0.0", 29 | "@types/react-dom": "^17.0.0", 30 | "@types/react-syntax-highlighter": "^15.5.4", 31 | "@vitejs/plugin-react-refresh": "^1.3.1", 32 | "concurrently": "^6.3.0", 33 | "esbuild": "^0.13.5", 34 | "prettier": "^2.7.1", 35 | "typescript": "^4.4.2", 36 | "vite": "^3.1.8", 37 | "vite-plugin-singlefile": "^0.12.3", 38 | "vite-svg-loader": "^3.6.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /plugin-src/adapter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | asBoolean, 3 | asNumber, 4 | isBoolean, 5 | isNumber, 6 | RelevantComponentNode, 7 | } from "./utils"; 8 | import { 9 | SafeComponentMap, 10 | SafeProperties, 11 | SafeProperty, 12 | SafePropertyDefinitionMetaMap, 13 | SafePropertyDefinition, 14 | SafePropertyDefinitions, 15 | SafePropertyDefinitionsMap, 16 | SafePropertyReferencesMap, 17 | SafePropertyDefinitionMeta, 18 | } from "./types"; 19 | import { FormatSettings } from "../shared"; 20 | 21 | export interface Adapter { 22 | metas: SafePropertyDefinitionMetaMap; 23 | definitions: SafePropertyDefinitionsMap; 24 | components: SafeComponentMap; 25 | references: SafePropertyReferencesMap; 26 | formatters: { 27 | capitalizedNameFromName(name: string): string; 28 | componentJsCommentFromMeta( 29 | meta: SafePropertyDefinitionMeta, 30 | extra?: string 31 | ): string; 32 | hyphenatedNameFromName(name: string): string; 33 | propertyNameFromKey(key: string): string; 34 | slotTagFromKey(key: string): string; 35 | }; 36 | } 37 | 38 | export function adapter( 39 | nodes: RelevantComponentNode[], 40 | settings: FormatSettings 41 | ): Adapter { 42 | const metas: SafePropertyDefinitionMetaMap = {}; 43 | const definitions: SafePropertyDefinitionsMap = {}; 44 | const components: SafeComponentMap = {}; 45 | const references: SafePropertyReferencesMap = { 46 | instances: {}, 47 | characterNodes: {}, 48 | visibleNodes: {}, 49 | properties: {}, 50 | }; 51 | nodes.forEach((node) => { 52 | components[node.id] = processNodeInToSafeComponent( 53 | node, 54 | definitions, 55 | metas, 56 | references 57 | ); 58 | }); 59 | 60 | return { 61 | components, 62 | definitions, 63 | metas, 64 | references, 65 | formatters: { 66 | capitalizedNameFromName: (key: string) => 67 | capitalizedNameFromName(key, settings), 68 | componentJsCommentFromMeta: ( 69 | meta: SafePropertyDefinitionMeta, 70 | extra = "" 71 | ) => componentJsCommentFromMeta(meta, extra, settings), 72 | hyphenatedNameFromName: (key: string) => 73 | hyphenatedNameFromName(key, settings), 74 | propertyNameFromKey: (key: string) => propertyNameFromKey(key, settings), 75 | slotTagFromKey: (key: string) => slotTagFromKey(key, settings), 76 | }, 77 | }; 78 | 79 | function processNodeInToSafeComponent( 80 | node: RelevantComponentNode, 81 | allDefinitions: SafePropertyDefinitionsMap, 82 | allMetas: SafePropertyDefinitionMetaMap, 83 | allReferences: SafePropertyReferencesMap 84 | ) { 85 | const definitionNode = 86 | node.type === "INSTANCE" 87 | ? node.mainComponent?.parent?.type === "COMPONENT_SET" 88 | ? node.mainComponent?.parent 89 | : node.mainComponent 90 | : node; 91 | const definition = definitionNode?.id || ""; 92 | const name = capitalizedNameFromName( 93 | definitionNode?.name || "UnnamedComponent", 94 | settings 95 | ); 96 | const componentPropertyDefinitions = getComponentPropertyDefinitions(node); 97 | const componentProperties = getComponentProperties(node); 98 | setSafePropertyReferencesMap(node, allReferences); 99 | allDefinitions[definition] = 100 | allDefinitions[definition] || 101 | getSafePropertyDefinitions(componentPropertyDefinitions, allReferences); 102 | const atFigma = `@figma component:${definitionNode?.key}`; 103 | const documentationLinks = 104 | definitionNode?.documentationLinks?.map(({ uri }) => uri) || []; 105 | documentationLinks.push(atFigma); 106 | allMetas[definition] = { 107 | name, 108 | id: definition, 109 | description: definitionNode?.description, 110 | documentationLinks, 111 | }; 112 | return { 113 | id: node.id, 114 | name, 115 | definition, 116 | properties: getSafeProperties( 117 | allDefinitions[definition], 118 | componentProperties, 119 | allReferences 120 | ), 121 | }; 122 | } 123 | 124 | function createSafePropertyDefinition( 125 | key: string, 126 | componentPropertyDefinitions: ComponentPropertyDefinitions, 127 | references: SafePropertyReferencesMap 128 | ): SafePropertyDefinition { 129 | const definition = componentPropertyDefinitions[key]; 130 | const { type } = definition; 131 | const variantOptions = (definition.variantOptions || []).map((option) => 132 | optionNameFromVariant(option, settings) 133 | ); 134 | const rawValue = 135 | type === "VARIANT" 136 | ? optionNameFromVariant(`${definition.defaultValue}`, settings) 137 | : `${definition.defaultValue}`; 138 | const name = propertyNameFromKey(key, settings); 139 | const hidden = 140 | settings.prefixIgnore && 141 | key.substring(0, settings.prefixIgnore.length) === settings.prefixIgnore 142 | ? true 143 | : undefined; 144 | 145 | if (type === "VARIANT") { 146 | if (variantOptions.length === 1) { 147 | const defaultValue = isNumber(rawValue) 148 | ? asNumber(rawValue) 149 | : isBoolean(rawValue) 150 | ? asBoolean(rawValue) 151 | : rawValue; 152 | return { 153 | name, 154 | type: "EXPLICIT", 155 | defaultValue, 156 | hidden, 157 | }; 158 | } 159 | 160 | if (variantOptions.length === 2) { 161 | if (isBoolean(variantOptions[0]) && isBoolean(variantOptions[1])) { 162 | return { 163 | name, 164 | type: "BOOLEAN", 165 | defaultValue: asBoolean(rawValue), 166 | hidden, 167 | }; 168 | } 169 | } 170 | 171 | // we could disable this if number variants were desired 172 | if (!variantOptions.map(isNumber).includes(false)) { 173 | return { 174 | name, 175 | type: "NUMBER", 176 | defaultValue: asNumber(rawValue), 177 | hidden, 178 | }; 179 | } 180 | } 181 | 182 | switch (type) { 183 | case "VARIANT": 184 | const optionalIdx = settings.valueOptional 185 | ? variantOptions.indexOf(settings.valueOptional) 186 | : -1; 187 | let optional = optionalIdx !== -1; 188 | if (optional) { 189 | variantOptions.splice(optionalIdx, 1); 190 | } 191 | return { 192 | name, 193 | type: type, 194 | defaultValue: rawValue, 195 | variantOptions, 196 | optional, 197 | hidden, 198 | }; 199 | case "BOOLEAN": 200 | const ref = references.properties[key]; 201 | const nodes = Object.keys(ref?.visibleNodes || {}); 202 | return { 203 | name, 204 | type, 205 | defaultValue: asBoolean(rawValue), 206 | hidden: 207 | hidden || 208 | Boolean(ref?.visibleProperties) || 209 | Boolean(nodes.find((id) => Boolean(references.characterNodes[id]))), 210 | }; 211 | case "INSTANCE_SWAP": 212 | const instanceOptions: InstanceSwapPreferredValue[] = 213 | definition.preferredValues || []; 214 | return { 215 | name, 216 | type, 217 | defaultValue: rawValue, 218 | instanceOptions, 219 | optional: 220 | figma.getNodeById(rawValue)?.name === settings.valueOptional, 221 | hidden, 222 | }; 223 | case "TEXT": 224 | return isNumber(rawValue) 225 | ? { 226 | name, 227 | type: "NUMBER", 228 | defaultValue: asNumber(rawValue), 229 | hidden, 230 | } 231 | : { 232 | name, 233 | type, 234 | defaultValue: rawValue, 235 | hidden, 236 | }; 237 | default: 238 | return { 239 | name, 240 | type, 241 | defaultValue: rawValue, 242 | hidden, 243 | }; 244 | } 245 | } 246 | 247 | function createSafeProperty( 248 | key: string, 249 | definition: SafePropertyDefinition, 250 | componentProperties: ComponentProperties, 251 | references: SafePropertyReferencesMap 252 | ): SafeProperty { 253 | const { type, defaultValue } = definition; 254 | const property = componentProperties[key]; 255 | const valueString = `${property.value}`; 256 | const valueBoolean = asBoolean(valueString); 257 | const valueNumber = asNumber(valueString); 258 | const name = propertyNameFromKey(key, settings); 259 | switch (type) { 260 | case "BOOLEAN": 261 | return { 262 | name, 263 | type, 264 | value: valueBoolean, 265 | default: valueBoolean === defaultValue, 266 | }; 267 | case "EXPLICIT": 268 | const value = isNumber(valueString) 269 | ? valueNumber 270 | : isBoolean(valueString) 271 | ? valueBoolean 272 | : valueString; 273 | return { 274 | name, 275 | type, 276 | value, 277 | default: value === defaultValue, 278 | }; 279 | case "NUMBER": 280 | return { 281 | name, 282 | type, 283 | value: valueNumber, 284 | default: valueNumber === defaultValue, 285 | }; 286 | case "INSTANCE_SWAP": 287 | return { 288 | name, 289 | type, 290 | value: valueString, 291 | undefined: 292 | definition.optional && 293 | figma.getNodeById(valueString)?.name === settings.valueOptional, 294 | default: valueString === defaultValue, 295 | }; 296 | case "TEXT": 297 | const charNodeId = Object.keys( 298 | references.properties[key]?.characterNodes || {} 299 | )[0]; 300 | return { 301 | name, 302 | type, 303 | value: valueString, 304 | undefined: charNodeId 305 | ? componentProperties[references.visibleNodes[charNodeId]] 306 | ?.value === false 307 | : false, 308 | default: valueString === defaultValue, 309 | }; 310 | case "VARIANT": 311 | return { 312 | name, 313 | type, 314 | value: optionNameFromVariant(valueString, settings), 315 | undefined: 316 | definition.optional && valueString === settings.valueOptional, 317 | default: valueString === defaultValue, 318 | }; 319 | } 320 | } 321 | 322 | function getSafePropertyDefinitions( 323 | componentPropertyDefinitions: ComponentPropertyDefinitions, 324 | references: SafePropertyReferencesMap 325 | ) { 326 | const defs: { [k: string]: SafePropertyDefinition } = {}; 327 | for (let key in componentPropertyDefinitions) { 328 | defs[key] = createSafePropertyDefinition( 329 | key, 330 | componentPropertyDefinitions, 331 | references 332 | ); 333 | } 334 | return defs; 335 | } 336 | 337 | function getSafeProperties( 338 | definitions: SafePropertyDefinitions, 339 | componentProperties: ComponentProperties, 340 | references: SafePropertyReferencesMap 341 | ) { 342 | const properties: SafeProperties = {}; 343 | for (let key in componentProperties) { 344 | properties[key] = createSafeProperty( 345 | key, 346 | definitions[key], 347 | componentProperties, 348 | references 349 | ); 350 | } 351 | return properties; 352 | } 353 | 354 | function getComponentPropertyDefinitions(node: RelevantComponentNode) { 355 | if (node.type === "INSTANCE") { 356 | const parent = 357 | node.mainComponent?.parent?.type === "COMPONENT_SET" 358 | ? node.mainComponent?.parent 359 | : node.mainComponent; 360 | const parentDefinitions = parent?.componentPropertyDefinitions || {}; 361 | return parentDefinitions; 362 | } else { 363 | return node.componentPropertyDefinitions; 364 | } 365 | } 366 | 367 | function getComponentProperties(node: RelevantComponentNode) { 368 | if (node.type === "INSTANCE") { 369 | return node.componentProperties; 370 | } else { 371 | const componentProperties: ComponentProperties = {}; 372 | for (let key in node.componentPropertyDefinitions) { 373 | const { type, defaultValue } = node.componentPropertyDefinitions[key]; 374 | componentProperties[key] = { type, value: defaultValue }; 375 | } 376 | return componentProperties; 377 | } 378 | } 379 | 380 | function setSafePropertyReferencesMap( 381 | node: RelevantComponentNode, 382 | allReferences: SafePropertyReferencesMap 383 | ) { 384 | const referenceNode = 385 | node.type === "COMPONENT" 386 | ? node 387 | : node.type === "INSTANCE" 388 | ? node.mainComponent 389 | : node; 390 | const recurse = ( 391 | nodes: readonly SceneNode[] = [], 392 | ancestorVisible = "" 393 | ) => { 394 | nodes.forEach((node) => { 395 | let localAncestorVisible = ancestorVisible; 396 | if (node.componentPropertyReferences) { 397 | const { characters, visible, mainComponent } = 398 | node.componentPropertyReferences; 399 | if (mainComponent) { 400 | // nested instances 401 | const visibleValue = 402 | !visible && !characters && ancestorVisible 403 | ? ancestorVisible 404 | : visible; 405 | allReferences.instances[mainComponent] = { 406 | visible: visibleValue, 407 | characters, 408 | }; 409 | } 410 | if (characters || visible) { 411 | if (characters) { 412 | allReferences.properties[characters] = 413 | allReferences.properties[characters] || {}; 414 | allReferences.properties[characters].characterNodes = { 415 | ...allReferences.properties[characters].characterNodes, 416 | [node.id]: true, 417 | }; 418 | allReferences.characterNodes[node.id] = characters; 419 | if (!visible && ancestorVisible) { 420 | // nested text node 421 | allReferences.visibleNodes[node.id] = ancestorVisible; 422 | allReferences.properties[ancestorVisible].visibleNodes = { 423 | ...allReferences.properties[ancestorVisible].visibleNodes, 424 | [node.id]: true, 425 | }; 426 | } 427 | } 428 | if (visible) { 429 | localAncestorVisible = visible; 430 | allReferences.properties[visible] = 431 | allReferences.properties[visible] || {}; 432 | if (node.type === "INSTANCE" || node.type === "TEXT") { 433 | allReferences.visibleNodes[node.id] = visible; 434 | if (mainComponent) { 435 | allReferences.properties[visible].visibleProperties = { 436 | ...allReferences.properties[visible].visibleProperties, 437 | [mainComponent]: true, 438 | }; 439 | } else { 440 | allReferences.properties[visible].visibleNodes = { 441 | ...allReferences.properties[visible].visibleNodes, 442 | [node.id]: true, 443 | }; 444 | } 445 | } 446 | } 447 | } else if ( 448 | ancestorVisible && 449 | node.type === "INSTANCE" && 450 | mainComponent 451 | ) { 452 | // handling nested instance 453 | allReferences.visibleNodes[node.id] = ancestorVisible; 454 | allReferences.properties[ancestorVisible].visibleProperties = { 455 | ...allReferences.properties[ancestorVisible].visibleProperties, 456 | [mainComponent]: true, 457 | }; 458 | } 459 | } 460 | if ("children" in node) { 461 | recurse(node.children, localAncestorVisible); 462 | } 463 | }); 464 | }; 465 | if (referenceNode) { 466 | recurse(referenceNode.children); 467 | } 468 | } 469 | } 470 | 471 | function componentJsCommentFromMeta( 472 | meta: SafePropertyDefinitionMeta, 473 | extra: string, 474 | settings: FormatSettings 475 | ): string { 476 | let documentation = [ 477 | ...splitString(meta.description || "", 50).map((s, i) => 478 | i === 0 ? s : ` ${s}` 479 | ), 480 | ...meta.documentationLinks, 481 | ] 482 | .filter(Boolean) 483 | .map((a) => ` * ${a}`) 484 | .join("\n"); 485 | const componentName = capitalizedNameFromName(meta.name, settings); 486 | return [ 487 | `/**`, 488 | ` * ${componentName} Component${extra}${ 489 | documentation ? `\n${documentation}` : "" 490 | }`, 491 | ` */`, 492 | ].join("\n"); 493 | } 494 | 495 | function capitalize(name: string) { 496 | return `${name.charAt(0).toUpperCase()}${name.slice(1)}`; 497 | } 498 | 499 | function downcase(name: string) { 500 | return `${name.charAt(0).toLowerCase()}${name.slice(1)}`; 501 | } 502 | 503 | function numericGuard(name: string) { 504 | if (name.charAt(0).match(/\d/)) { 505 | name = `N${name}`; 506 | } 507 | return name; 508 | } 509 | 510 | function capitalizedNameFromName(name: string, _settings: FormatSettings) { 511 | name = numericGuard(name); 512 | return name 513 | .split(/[^a-zA-Z\d]+/g) 514 | .map(capitalize) 515 | .join(""); 516 | } 517 | 518 | function hyphenatedNameFromName(name: string, _settings: FormatSettings) { 519 | name = numericGuard(name); 520 | return name 521 | .replace(/[^a-zA-Z\d-_]/g, "") 522 | .replace(/[ _]+/g, "-") 523 | .replace(/([A-Z])/g, "-$1") 524 | .replace(/-+/g, "-") 525 | .replace(/^-/, "") 526 | .toLowerCase(); 527 | } 528 | 529 | function escapeRegExp(string: string) { 530 | return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 531 | } 532 | 533 | function propertyNameFromKey(name: string, settings: FormatSettings) { 534 | name = name.replace(/#[^#]+$/g, ""); 535 | if (settings.suffixSlot) { 536 | name = name.replace( 537 | new RegExp(`${escapeRegExp(settings.suffixSlot)}.+$`, "g"), 538 | "" 539 | ); 540 | } 541 | return downcase(capitalizedNameFromName(name, settings).replace(/^\d+/g, "")); 542 | } 543 | 544 | function optionNameFromVariant(name: string, settings: FormatSettings) { 545 | const clean = name.replace(/[^a-zA-Z\d-_ ]/g, ""); 546 | if (clean.match("-")) { 547 | return clean.replace(/ +/g, "-").toLowerCase(); 548 | } else if (clean.match("_")) { 549 | return clean.replace(/ +/g, "_").toLowerCase(); 550 | } else if (clean.match(" ") || clean.match(/^[A-Z]/)) { 551 | return clean 552 | .split(/ +/) 553 | .map((a, i) => { 554 | let text = 555 | i > 0 556 | ? `${a.charAt(0).toUpperCase()}${a.substring(1).toLowerCase()}` 557 | : a.toLowerCase(); 558 | return text; 559 | }) 560 | .join(""); 561 | } else return clean; 562 | } 563 | 564 | function slotTagFromKey(key: string, settings: FormatSettings) { 565 | if (!settings.suffixSlot) { 566 | return ""; 567 | } 568 | const match = key.match( 569 | new RegExp(`${escapeRegExp(settings.suffixSlot)}(\\[([a-zA-Z0-9-]+)\\])?`) 570 | ); 571 | return match ? match[2] || "span" : ""; 572 | } 573 | 574 | function splitString(string: string, maxLength: number): string[] { 575 | const arr = string?.split(" "); 576 | const result = []; 577 | let subString = arr[0]; 578 | for (let i = 1; i < arr.length; i++) { 579 | const word = arr[i]; 580 | if (subString.length + word.length + 1 <= maxLength) { 581 | subString = subString + " " + word; 582 | } else { 583 | result.push(subString); 584 | subString = word; 585 | } 586 | } 587 | if (subString.length) { 588 | result.push(subString); 589 | } 590 | return result; 591 | } 592 | -------------------------------------------------------------------------------- /plugin-src/code.ts: -------------------------------------------------------------------------------- 1 | import { adapter } from "./adapter"; 2 | import { componentNodesFromSceneNodes } from "./utils"; 3 | import { format as formatAngular } from "./formatAngular"; 4 | import { format as formatReact } from "./formatReact"; 5 | import { format as formatJSON } from "./formatJSON"; 6 | import { format as formatVue } from "./formatVue"; 7 | import { format as formatWebComponents } from "./formatWebComponents"; 8 | import { FormatLanguage, FormatResult, PluginMessage } from "../shared"; 9 | import { isFigmaCommand, readSettings, writeSettings } from "./config"; 10 | 11 | if (figma.mode === "codegen") { 12 | initializeCodegen(); 13 | } else { 14 | initialize(); 15 | } 16 | 17 | async function initializeCodegen() { 18 | let watching: { [id: string]: boolean } = {}; 19 | figma.codegen.on("generate", (event) => { 20 | const { node } = event; 21 | const main = "mainComponent" in node ? node.mainComponent : null; 22 | const parent = node.parent; 23 | watching = {}; 24 | switch (node.type) { 25 | case "INSTANCE": 26 | watching[node.id] = true; 27 | if (main) watching[main.id] = true; 28 | if (main?.parent) watching[main.parent.id] = true; 29 | break; 30 | case "COMPONENT": 31 | watching[node.id] = true; 32 | if (parent) watching[parent.id] = true; 33 | break; 34 | case "COMPONENT_SET": 35 | watching[node.id] = true; 36 | break; 37 | } 38 | return runCodegen(event); 39 | }); 40 | figma.on("documentchange", ({ documentChanges }) => { 41 | if (documentChanges.find(({ id }) => watching[id])) { 42 | figma.codegen.refresh(); 43 | } 44 | }); 45 | figma.codegen.on("preferenceschange", async (event) => { 46 | if (event.propertyName === "settings") { 47 | figma.showUI(__html__, { 48 | visible: true, 49 | width: 500, 50 | height: 500, 51 | themeColors: true, 52 | }); 53 | const settings = await readSettings(); 54 | const message: PluginMessage = { 55 | type: "CONFIG", 56 | settings, 57 | codegen: true, 58 | }; 59 | figma.ui.postMessage(message); 60 | figma.ui.onmessage = async (message) => { 61 | if (message.type === "SETTINGS") { 62 | settings.prefixIgnore = message.settings.prefixIgnore; 63 | settings.suffixSlot = message.settings.suffixSlot; 64 | settings.valueOptional = message.settings.valueOptional; 65 | settings.scale = message.settings.scale; 66 | writeSettings(settings); 67 | } 68 | }; 69 | } 70 | }); 71 | } 72 | 73 | // defaulting to angular because due to a bug, the default language is missing when run from search panel 74 | async function runCodegen({ 75 | node, 76 | language, 77 | }: CodegenEvent): Promise { 78 | const settings = await readSettings(); 79 | const relevantNodes = componentNodesFromSceneNodes([node]); 80 | return new Promise((resolve, reject) => { 81 | if (!relevantNodes.length) { 82 | return resolve([ 83 | { 84 | title: "Component Inspector", 85 | code: "Select a component", 86 | language: "PLAINTEXT", 87 | }, 88 | ]); 89 | } 90 | language = language || "angular"; 91 | settings.singleNode = true; 92 | const result = adapter(relevantNodes, settings); 93 | const things = []; 94 | if (language === "angular") { 95 | things.push(formatAngular(result, settings)); 96 | } else if (language === "react") { 97 | things.push(formatReact(result, settings)); 98 | } else if (language === "vue-composition") { 99 | settings.options.definitionVue[0][1] = 0; 100 | writeSettings(settings); 101 | things.push(formatVue(result, settings)); 102 | } else if (language === "vue-options") { 103 | settings.options.definitionVue[0][1] = 1; 104 | writeSettings(settings); 105 | things.push(formatVue(result, settings)); 106 | } else if (language === "json") { 107 | things.push(formatJSON(result, settings)); 108 | } else if (language === "web") { 109 | things.push(formatWebComponents(result, settings)); 110 | } 111 | const readyToFormat: { 112 | lines: string[]; 113 | language: string; 114 | title: string; 115 | }[] = []; 116 | things.forEach(({ label, items }) => { 117 | items.forEach(({ label: itemLabel, code }) => { 118 | code.forEach(({ label, language, lines }) => { 119 | readyToFormat.push({ 120 | lines, 121 | language, 122 | title: itemLabel + (label ? `: ${label}` : ""), 123 | }); 124 | }); 125 | }); 126 | }); 127 | let promiseCount = readyToFormat.length; 128 | const results: CodegenResult[] = []; 129 | figma.showUI(__html__, { visible: false }); 130 | figma.ui.onmessage = (message) => { 131 | if (message.type === "FORMAT_RESULT") { 132 | const item = readyToFormat[message.index]; 133 | const result: CodegenResult = { 134 | title: item.title, 135 | code: message.result, 136 | language: ["vue", "angular", "html", "jsx"].includes(item.language) 137 | ? "HTML" 138 | : item.language === "json" 139 | ? "JSON" 140 | : "TYPESCRIPT", 141 | }; 142 | results.push(result); 143 | promiseCount--; 144 | if (promiseCount <= 0) { 145 | resolve(results); 146 | } 147 | } 148 | }; 149 | if (promiseCount === 0) { 150 | return resolve([]); 151 | } 152 | 153 | readyToFormat.forEach(({ lines, language }, index) => { 154 | const message: PluginMessage = { 155 | type: "FORMAT", 156 | lines, 157 | language: language as FormatLanguage, 158 | index, 159 | }; 160 | figma.ui.postMessage(message); 161 | }); 162 | }); 163 | } 164 | 165 | async function initialize() { 166 | figma.showUI(__html__, { 167 | visible: true, 168 | width: 500, 169 | height: 700, 170 | themeColors: true, 171 | }); 172 | 173 | const settings = await readSettings(); 174 | 175 | const nodes: SceneNode[] = []; 176 | 177 | function isFormatResult( 178 | result: false | FormatResult 179 | ): result is FormatResult { 180 | return result !== false; 181 | } 182 | 183 | function process() { 184 | const cmd = isFigmaCommand(figma.command) ? figma.command : "all"; 185 | if (cmd === "config") { 186 | const message: PluginMessage = { type: "CONFIG", settings }; 187 | figma.ui.postMessage(message); 188 | 189 | return; 190 | } 191 | 192 | const relevantNodes = componentNodesFromSceneNodes(nodes); 193 | const result = adapter(relevantNodes, settings); 194 | const all = cmd === "all"; 195 | const results = [ 196 | (all || cmd === "angular") && formatAngular(result, settings), 197 | (all || cmd === "react") && formatReact(result, settings), 198 | (all || cmd === "vue") && formatVue(result, settings), 199 | (all || cmd === "web") && formatWebComponents(result, settings), 200 | (all || cmd === "json") && formatJSON(result, settings), 201 | ].filter(isFormatResult); 202 | 203 | const message: PluginMessage = { 204 | type: "RESULT", 205 | results, 206 | settings, 207 | }; 208 | figma.ui.postMessage(message); 209 | } 210 | 211 | function run() { 212 | nodes.splice(0, nodes.length); 213 | figma.currentPage.selection.forEach((node) => nodes.push(node)); 214 | process(); 215 | } 216 | 217 | figma.ui.onmessage = async (message) => { 218 | if (message.type === "OPTIONS") { 219 | settings.tab = message.tab; 220 | settings.tabIndex = message.tabIndex; 221 | if (message.options && message.optionsKey) { 222 | settings.options[message.optionsKey] = [...message.options]; 223 | } 224 | writeSettings(settings); 225 | process(); 226 | } else if (message.type === "SETTINGS") { 227 | settings.prefixIgnore = message.settings.prefixIgnore; 228 | settings.suffixSlot = message.settings.suffixSlot; 229 | settings.valueOptional = message.settings.valueOptional; 230 | settings.scale = message.settings.scale; 231 | console.log(settings); 232 | writeSettings(settings); 233 | } 234 | }; 235 | 236 | figma.on("selectionchange", run); 237 | run(); 238 | 239 | figma.on("documentchange", ({ documentChanges }) => { 240 | const relevantChange = documentChanges.find( 241 | (change: DocumentChange) => 242 | change.type === "PROPERTY_CHANGE" && 243 | ["COMPONENT", "INSTANCE", "COMPONENT_SET"].includes(change.node.type) 244 | ); 245 | if (relevantChange) run(); 246 | }); 247 | } 248 | -------------------------------------------------------------------------------- /plugin-src/config.ts: -------------------------------------------------------------------------------- 1 | import { FormatSettings, FormatSettingsScale } from "../shared"; 2 | 3 | const SETTINGS_STORAGE_KEY = "settings"; 4 | const SETTINGS_VERSION = "3"; 5 | 6 | export function generateBooleans() { 7 | if (figma.editorType !== "dev") return null; 8 | return figma.codegen?.preferences?.customSettings?.boolean === "explicit"; 9 | } 10 | export function generateComments() { 11 | if (figma.editorType !== "dev") return true; 12 | return figma.codegen?.preferences?.customSettings?.comments === "enabled"; 13 | } 14 | export function generateDefaults() { 15 | if (figma.editorType !== "dev") return null; 16 | return figma.codegen?.preferences?.customSettings?.defaults === "shown"; 17 | } 18 | 19 | export async function readSettings(): Promise { 20 | const saved: FormatSettings = await figma.clientStorage.getAsync( 21 | SETTINGS_STORAGE_KEY 22 | ); 23 | const initial = 24 | saved && saved.version === SETTINGS_VERSION 25 | ? saved 26 | : { 27 | tab: undefined, 28 | tabIndex: undefined, 29 | version: SETTINGS_VERSION, 30 | options: {}, 31 | prefixIgnore: "", 32 | suffixSlot: "--SLOT", 33 | valueOptional: "undefined", 34 | scale: "sm" as FormatSettingsScale, 35 | }; 36 | const settings: FormatSettings = { 37 | version: SETTINGS_VERSION, 38 | options: { 39 | instance: [ 40 | ["Default", 1], 41 | ["Boolean", 0], 42 | ], 43 | definitionVue: [[["Composition API", "Option API"], 0]], 44 | ...initial.options, 45 | }, 46 | tab: initial.tab, 47 | tabIndex: initial.tabIndex, 48 | prefixIgnore: initial.prefixIgnore, 49 | suffixSlot: initial.suffixSlot, 50 | valueOptional: initial.valueOptional, 51 | scale: initial.scale, 52 | }; 53 | writeSettings(settings); 54 | return settings; 55 | } 56 | 57 | export async function writeSettings(settings: FormatSettings) { 58 | return await figma.clientStorage.setAsync(SETTINGS_STORAGE_KEY, settings); 59 | } 60 | 61 | type FigmaCommand = 62 | | "all" 63 | | "angular" 64 | | "react" 65 | | "vue" 66 | | "web" 67 | | "json" 68 | | "config"; 69 | const commands: FigmaCommand[] = [ 70 | "all", 71 | "angular", 72 | "react", 73 | "vue", 74 | "web", 75 | "json", 76 | "config", 77 | ]; 78 | 79 | export function isFigmaCommand( 80 | string: string | FigmaCommand 81 | ): string is FigmaCommand { 82 | return commands.includes(string as FigmaCommand); 83 | } 84 | -------------------------------------------------------------------------------- /plugin-src/formatAngular.ts: -------------------------------------------------------------------------------- 1 | import { Adapter } from "./adapter"; 2 | import { 3 | FormatLanguage, 4 | FormatResult, 5 | FormatResultItem, 6 | FormatSettings, 7 | } from "../shared"; 8 | import { 9 | SafeProperty, 10 | SafePropertyDefinition, 11 | SafePropertyDefinitionMetaMap, 12 | SafePropertyDefinitions, 13 | } from "./types"; 14 | import { 15 | formatInstancesInstanceFromComponent, 16 | slotKeysFromDefinitions, 17 | } from "./formatShared"; 18 | import { generateBooleans, generateComments, generateDefaults } from "./config"; 19 | 20 | export function format( 21 | adapter: Adapter, 22 | settings: FormatSettings 23 | ): FormatResult { 24 | const items = []; 25 | items.push( 26 | formatInstances(adapter, settings), 27 | formatDefinitions(adapter, settings) 28 | ); 29 | return { 30 | label: "Angular", 31 | items, 32 | }; 33 | } 34 | 35 | function formatDefinitions( 36 | adapter: Adapter, 37 | settings: FormatSettings 38 | ): FormatResultItem { 39 | const { definitions, metas } = adapter; 40 | const code: { language: FormatLanguage; lines: string[] }[] = []; 41 | Object.entries(definitions).forEach(([key, definition]) => { 42 | code.push({ 43 | language: "ts", 44 | lines: [ 45 | generateComments() 46 | ? adapter.formatters.componentJsCommentFromMeta(metas[key]) 47 | : "", 48 | formatDefinitionsVariantOptionTypes(metas[key].name, definition).join( 49 | "\n" 50 | ), 51 | ...formatDefinitionsComponentClass(key, definition, metas), 52 | ], 53 | }); 54 | }); 55 | return { 56 | label: settings.singleNode ? "Definition" : "Definitions", 57 | code, 58 | options: [], 59 | }; 60 | 61 | // https://angular.io/guide/content-projection#multi-slot 62 | function formatDefinitionsComponentClass( 63 | key: string, 64 | definitions: SafePropertyDefinitions, 65 | metas: SafePropertyDefinitionMetaMap 66 | ): string[] { 67 | const meta = metas[key]; 68 | const keys = Object.keys(definitions).sort(); 69 | const { slotKeys, slotTextKeys, hasOneTextProperty } = 70 | slotKeysFromDefinitions(adapter, definitions, true); 71 | const capitalizedName = adapter.formatters.capitalizedNameFromName( 72 | meta.name 73 | ); 74 | const template = slotKeys.map((key) => 75 | hasOneTextProperty && 76 | key === slotTextKeys[0] && 77 | !adapter.formatters.slotTagFromKey(key) 78 | ? `` 79 | : `` 82 | ); 83 | const templateDefinition = 84 | template.length > 1 85 | ? `const template${capitalizedName} = [${template 86 | .map((a) => `\`${a}\``) 87 | .join(",")}].join("");\n\n` 88 | : template.length 89 | ? `const template${capitalizedName} = \`${template[0]}\`;\n\n` 90 | : ""; 91 | return [ 92 | `${templateDefinition}@Component({ selector: '${adapter.formatters.hyphenatedNameFromName( 93 | meta.name 94 | )}'${ 95 | templateDefinition ? `, template: template${capitalizedName}` : "" 96 | } })`, 97 | `class ${capitalizedName} {`, 98 | keys 99 | .map((key) => 100 | definitions[key].hidden || slotKeys.includes(key) 101 | ? null 102 | : formatDefinitionsInputProperty(meta.name, definitions[key]) 103 | ) 104 | .filter(Boolean) 105 | .join("\n"), 106 | "}", 107 | ]; 108 | } 109 | 110 | function formatDefinitionsInputProperty( 111 | componentName: string, 112 | definition: SafePropertyDefinition 113 | ): string { 114 | const { name, type, defaultValue, optional } = definition; 115 | const clean = adapter.formatters.propertyNameFromKey(name); 116 | if (type === "BOOLEAN") { 117 | return `@Input() ${clean}?: boolean = ${defaultValue};`; 118 | } else if (type === "INSTANCE_SWAP") { 119 | const node = figma.getNodeById(defaultValue); 120 | const value = node 121 | ? node.name === settings.valueOptional 122 | ? "" 123 | : ` = "${adapter.formatters.capitalizedNameFromName(node.name)}";` 124 | : ` = "${defaultValue}"`; 125 | return node 126 | ? `@Input() ${clean}?: Component${value};` 127 | : `@Input() ${clean}?: string${value};`; 128 | } else if (type === "NUMBER") { 129 | return `@Input() ${clean}?: number = ${defaultValue};`; 130 | } else if (type === "VARIANT") { 131 | return `@Input() ${clean}?: ${typeNameForComponentProperty( 132 | componentName, 133 | name 134 | )}${ 135 | optional && defaultValue === settings.valueOptional 136 | ? "" 137 | : ` = "${defaultValue}";` 138 | }`; 139 | } else { 140 | return `@Input() ${clean}?: string = "${defaultValue}";`; 141 | } 142 | } 143 | 144 | function formatDefinitionsVariantOptionTypes( 145 | componentName: string, 146 | definitions: SafePropertyDefinitions 147 | ): string[] { 148 | const types: string[] = []; 149 | Object.entries(definitions).forEach(([key, definition]) => { 150 | if (definition.type === "VARIANT" && !definition.hidden) { 151 | types.push( 152 | `type ${typeNameForComponentProperty( 153 | componentName, 154 | definition.name 155 | )} = ${definition.variantOptions.map((o) => `'${o}'`).join(" | ")}` 156 | ); 157 | } 158 | }); 159 | return types; 160 | } 161 | 162 | function typeNameForComponentProperty(componentName: string, name: string) { 163 | return `${adapter.formatters.capitalizedNameFromName( 164 | componentName 165 | )}${adapter.formatters.capitalizedNameFromName(name)}`; 166 | } 167 | } 168 | 169 | function formatInstances( 170 | adapter: Adapter, 171 | settings: FormatSettings 172 | ): FormatResultItem { 173 | let [showDefaults, explicitBoolean] = settings.options.instance.map((a) => 174 | Boolean(a[1]) 175 | ); 176 | showDefaults = 177 | generateDefaults() === null ? showDefaults : Boolean(generateDefaults()); 178 | explicitBoolean = 179 | generateDefaults() === null ? explicitBoolean : Boolean(generateBooleans()); 180 | 181 | const { components } = adapter; 182 | const lines: string[] = []; 183 | Object.values(components).forEach((component) => 184 | lines.push( 185 | formatInstancesInstanceFromComponent( 186 | component, 187 | adapter, 188 | showDefaults, 189 | explicitBoolean, 190 | formatInstancesAttributeFromProperty, 191 | adapter.formatters.hyphenatedNameFromName, 192 | slotFormatter, 193 | { 194 | instanceSlot: true, 195 | } 196 | ) 197 | ) 198 | ); 199 | return { 200 | label: settings.singleNode ? "Instance" : "Instances", 201 | code: [ 202 | { 203 | language: "angular", 204 | lines, 205 | }, 206 | ], 207 | options: settings.options.instance, 208 | optionsKey: "instance", 209 | }; 210 | 211 | function slotFormatter( 212 | tag: string, 213 | key: string, 214 | slotCount: number, 215 | isDefault = false, 216 | value: string = "" 217 | ) { 218 | const tagged = `<${tag} ${adapter.formatters.propertyNameFromKey( 219 | key 220 | )}>${value}`; 221 | return (isDefault || slotCount === 1) && value ? value : tagged; 222 | } 223 | 224 | function formatInstancesAttributeFromProperty( 225 | property: SafeProperty, 226 | name: string, 227 | explicitBoolean: boolean 228 | ) { 229 | if (property.undefined) { 230 | return ""; 231 | } 232 | const clean = adapter.formatters.propertyNameFromKey(name); 233 | if (property.type === "BOOLEAN") { 234 | return explicitBoolean 235 | ? `[${clean}]="${property.value}"` 236 | : property.value 237 | ? `[${clean}]` 238 | : ""; 239 | } else if (property.type === "INSTANCE_SWAP") { 240 | const node = figma.getNodeById(property.value); 241 | return node 242 | ? `[${clean}]="${adapter.formatters.capitalizedNameFromName( 243 | node.name 244 | )}"` 245 | : `[${clean}]="${property.value}"`; 246 | } else { 247 | return `[${clean}]="${property.value}"`; 248 | } 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /plugin-src/formatJSON.ts: -------------------------------------------------------------------------------- 1 | import { FormatResult, FormatResultItem, FormatSettings } from "../shared"; 2 | import { Adapter } from "./adapter"; 3 | 4 | export function format( 5 | adapter: Adapter, 6 | settings: FormatSettings 7 | ): FormatResult { 8 | const shared: FormatResultItem = { 9 | label: "", 10 | code: [], 11 | options: [], 12 | }; 13 | const lines = (object: any) => [JSON.stringify(object, null, 2)]; 14 | const items: FormatResultItem[] = settings.singleNode 15 | ? [ 16 | { 17 | ...shared, 18 | label: "Component Inspector JSON Schema", 19 | code: [{ language: "json", lines: lines(adapter) }], 20 | }, 21 | ] 22 | : [ 23 | { 24 | ...shared, 25 | label: "All", 26 | code: [{ language: "json", lines: lines(adapter) }], 27 | }, 28 | { 29 | ...shared, 30 | label: "Definitions", 31 | code: [{ language: "json", lines: lines(adapter.definitions) }], 32 | }, 33 | { 34 | ...shared, 35 | label: "Components", 36 | code: [{ language: "json", lines: lines(adapter.components) }], 37 | }, 38 | { 39 | ...shared, 40 | label: "References", 41 | code: [{ language: "json", lines: lines(adapter.references) }], 42 | }, 43 | { 44 | ...shared, 45 | label: "Metas", 46 | code: [{ language: "json", lines: lines(adapter.metas) }], 47 | }, 48 | ]; 49 | return { 50 | label: "JSON", 51 | items, 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /plugin-src/formatReact.ts: -------------------------------------------------------------------------------- 1 | import { Adapter } from "./adapter"; 2 | import { 3 | FormatLanguage, 4 | FormatResult, 5 | FormatResultItem, 6 | FormatSettings, 7 | } from "../shared"; 8 | import { 9 | SafeProperty, 10 | SafePropertyDefinition, 11 | SafePropertyDefinitions, 12 | SafePropertyDefinitionMetaMap, 13 | } from "./types"; 14 | import { 15 | formatInstancesInstanceFromComponent, 16 | SlotKeysData, 17 | slotKeysFromDefinitions, 18 | } from "./formatShared"; 19 | import { generateBooleans, generateComments, generateDefaults } from "./config"; 20 | 21 | type TypeDefinitionsObject = { [k: string]: string }; 22 | 23 | export function format( 24 | adapter: Adapter, 25 | settings: FormatSettings 26 | ): FormatResult { 27 | return { 28 | label: "React", 29 | items: [ 30 | formatInstances(adapter, settings), 31 | formatDefinitions(adapter, settings), 32 | ], 33 | }; 34 | } 35 | 36 | function formatInstances( 37 | adapter: Adapter, 38 | settings: FormatSettings 39 | ): FormatResultItem { 40 | const { components } = adapter; 41 | let [showDefaults, explicitBoolean] = settings.options.instance.map((a) => 42 | Boolean(a[1]) 43 | ); 44 | showDefaults = 45 | generateDefaults() === null ? showDefaults : Boolean(generateDefaults()); 46 | explicitBoolean = 47 | generateDefaults() === null ? explicitBoolean : Boolean(generateBooleans()); 48 | const lines = Object.values(components).map((component) => 49 | formatInstancesInstanceFromComponent( 50 | component, 51 | adapter, 52 | showDefaults, 53 | explicitBoolean, 54 | formatInstancesAttributeFromProperty, 55 | adapter.formatters.capitalizedNameFromName, 56 | slotFormatter, 57 | { selfClosing: true } 58 | ) 59 | ); 60 | return { 61 | label: settings.singleNode ? "Instance" : "Instances", 62 | code: [ 63 | { 64 | language: "jsx", 65 | lines, 66 | }, 67 | ], 68 | options: settings.options.instance, 69 | optionsKey: "instance", 70 | }; 71 | 72 | function slotFormatter( 73 | tag: string, 74 | _key: string, 75 | slotCount: number, 76 | isDefault = false, 77 | value: string = "" 78 | ) { 79 | const tagged = value ? `<${tag}>${value}` : `<${tag} />`; 80 | return slotCount === 1 && isDefault ? value : tagged; 81 | } 82 | 83 | function formatInstancesAttributeFromProperty( 84 | property: SafeProperty, 85 | name: string, 86 | explicitBoolean: boolean, 87 | slotTag?: string 88 | ) { 89 | const clean = adapter.formatters.propertyNameFromKey(name); 90 | if (property.undefined) { 91 | return ""; 92 | } 93 | if (property.type === "BOOLEAN") { 94 | return explicitBoolean 95 | ? `${clean}={${property.value}}` 96 | : property.value 97 | ? clean 98 | : ""; 99 | } else if (property.type === "NUMBER") { 100 | return `${clean}={${property.value}}`; 101 | } else if (property.type === "INSTANCE_SWAP") { 102 | const node = figma.getNodeById(property.value); 103 | return node 104 | ? `${clean}={<${adapter.formatters.capitalizedNameFromName( 105 | node.name 106 | )} />}` 107 | : `${clean}="${property.value}"`; 108 | } else if (property.type === "TEXT" && slotTag) { 109 | return `${clean}={<${slotTag}>${property.value}}`; 110 | } else { 111 | return `${clean}="${property.value}"`; 112 | } 113 | } 114 | } 115 | 116 | function formatDefinitions( 117 | adapter: Adapter, 118 | settings: FormatSettings 119 | ): FormatResultItem { 120 | const { definitions, metas } = adapter; 121 | const hasDefinitions = Object.keys(definitions).length; 122 | const code: { language: FormatLanguage; lines: string[] }[] = 123 | hasDefinitions && !settings.singleNode 124 | ? [ 125 | { 126 | language: "tsx", 127 | lines: [`import { FC, ReactNode, } from "react";`], 128 | }, 129 | ] 130 | : []; 131 | Object.keys(definitions).forEach((key) => { 132 | const types: TypeDefinitionsObject = {}; 133 | const properties = definitions[key]; 134 | const componentName = adapter.formatters.capitalizedNameFromName( 135 | metas[key].name 136 | ); 137 | const slotKeysData = slotKeysFromDefinitions(adapter, properties, true); 138 | const interfaceName = `${componentName}Props`; 139 | const interfaceLines = Object.keys(properties) 140 | .sort() 141 | .map((propName) => 142 | formatDefinitionsInterfaceProperties( 143 | interfaceName, 144 | propName, 145 | types, 146 | properties[propName], 147 | slotKeysData 148 | ) 149 | ) 150 | .filter(Boolean); 151 | 152 | code.push({ 153 | language: "tsx", 154 | lines: [ 155 | [ 156 | settings.singleNode ? `import { FC, ReactNode, } from "react";` : "", 157 | generateComments() 158 | ? adapter.formatters.componentJsCommentFromMeta(metas[key]) 159 | : "", 160 | Object.keys(types) 161 | .map((name) => `type ${name} = ${types[name]};`) 162 | .join("\n"), 163 | `interface ${interfaceName} { ${interfaceLines.join(" ")} }`, 164 | formatComponentFunctionFromDefinitionsAndMetas( 165 | key, 166 | properties, 167 | metas, 168 | slotKeysData 169 | ), 170 | ].join("\n\n"), 171 | ], 172 | }); 173 | }); 174 | 175 | return { 176 | label: settings.singleNode ? "Definition" : "Definitions", 177 | code, 178 | options: [], 179 | }; 180 | 181 | function formatDefinitionsInterfaceProperties( 182 | interfaceName: string, 183 | propName: string, 184 | types: TypeDefinitionsObject, 185 | definition: SafePropertyDefinition, 186 | { slotTextKeys, hasOneTextProperty }: SlotKeysData 187 | ) { 188 | if ( 189 | (hasOneTextProperty && propName === slotTextKeys[0]) || 190 | definition.hidden 191 | ) { 192 | return ""; 193 | } 194 | const name = adapter.formatters.propertyNameFromKey(propName); 195 | if (definition.type === "BOOLEAN") { 196 | return `${name}?: boolean;`; 197 | } else if (definition.type === "NUMBER") { 198 | return `${name}?: number;`; 199 | } else if (definition.type === "TEXT") { 200 | return `${name}?: ${ 201 | slotTextKeys.includes(propName) ? "ReactNode" : "string" 202 | };`; 203 | } else if (definition.type === "VARIANT") { 204 | const n = `${interfaceName}${adapter.formatters.capitalizedNameFromName( 205 | propName 206 | )}`; 207 | const value = (definition.variantOptions || []) 208 | .map((o) => `'${o}'`) 209 | .join(" | "); 210 | types[n] = value; 211 | return `${name}?: ${n};`; 212 | } else if (definition.type === "EXPLICIT") { 213 | return `${name}?: "${definition.defaultValue}";`; 214 | } else if (definition.type === "INSTANCE_SWAP") { 215 | return `${name}?: ReactNode;`; 216 | } else { 217 | return `${name}?: ${JSON.stringify(definition)};`; 218 | } 219 | } 220 | 221 | function formatComponentFunctionFromDefinitionsAndMetas( 222 | key: string, 223 | definitions: SafePropertyDefinitions, 224 | metas: SafePropertyDefinitionMetaMap, 225 | slotKeysData: SlotKeysData 226 | ): string { 227 | const { slotTextKeys, hasOneTextProperty } = slotKeysData; 228 | const meta = metas[key]; 229 | const keys = Object.keys(definitions).sort(); 230 | const destructuredProps = `{ 231 | ${keys 232 | .map((key) => 233 | hasOneTextProperty && slotTextKeys[0] === key 234 | ? null 235 | : formatDefinitionInputProperty( 236 | definitions[key], 237 | slotTextKeys.includes(key) 238 | ) 239 | ) 240 | .filter(Boolean) 241 | .join("\n")} 242 | ${hasOneTextProperty ? "children" : ""} 243 | }`; 244 | const propsName = `${adapter.formatters.capitalizedNameFromName( 245 | meta.name 246 | )}Props`; 247 | const children = generateChildren(keys, slotKeysData); 248 | return `const ${adapter.formatters.capitalizedNameFromName( 249 | meta.name 250 | )}: FC<${propsName}> = (${destructuredProps}) => (<>${children})`; 251 | } 252 | 253 | function generateChildren( 254 | keys: string[], 255 | { slotKeys, slotTextKeys, hasOneTextProperty }: SlotKeysData 256 | ) { 257 | const children: string[] = []; 258 | keys.forEach((key) => { 259 | if (slotTextKeys.includes(key)) { 260 | if (hasOneTextProperty) { 261 | children.push("children"); 262 | } else { 263 | children.push(adapter.formatters.propertyNameFromKey(key)); 264 | } 265 | } else if (slotKeys.includes(key)) { 266 | children.push(adapter.formatters.propertyNameFromKey(key)); 267 | } 268 | }); 269 | return children.map((c) => `{${c}}`).join("\n"); 270 | } 271 | 272 | function formatDefinitionInputProperty( 273 | definition: SafePropertyDefinition, 274 | hideDefaultValue: boolean 275 | ): string { 276 | const { name, type, defaultValue } = definition; 277 | const clean = adapter.formatters.propertyNameFromKey(name); 278 | if (definition.hidden) { 279 | return ""; 280 | } 281 | if ( 282 | (definition.optional && defaultValue === settings.valueOptional) || 283 | hideDefaultValue 284 | ) { 285 | return `${clean},`; 286 | } 287 | if (type === "BOOLEAN") { 288 | return `${clean} = ${defaultValue},`; 289 | } else if (type === "INSTANCE_SWAP") { 290 | const node = figma.getNodeById(defaultValue); 291 | if (definition.optional && node?.name === settings.valueOptional) { 292 | return `${clean},`; 293 | } 294 | return node 295 | ? `${clean} = <${adapter.formatters.capitalizedNameFromName( 296 | node.name 297 | )} />,` 298 | : `${clean} = "${defaultValue}",`; 299 | } else if (type === "NUMBER") { 300 | return `${clean} = ${defaultValue},`; 301 | } else if (type === "VARIANT") { 302 | return `${clean} = "${defaultValue}",`; 303 | } else { 304 | return `${clean} = "${defaultValue}",`; 305 | } 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /plugin-src/formatShared.ts: -------------------------------------------------------------------------------- 1 | import { Adapter } from "./adapter"; 2 | import { SafeComponent, SafeProperty, SafePropertyDefinitions } from "./types"; 3 | 4 | export interface SlotKeysData { 5 | slotKeys: string[]; 6 | slotTextKeys: string[]; 7 | hasInstanceSlots: boolean; 8 | hasOneTextProperty: boolean; 9 | } 10 | 11 | function isVisibleKey(key: string, component: SafeComponent, adapter: Adapter) { 12 | const definitions = adapter.definitions[component.definition]; 13 | const isToggledByBoolean = adapter.references.instances[key]?.visible; 14 | if (isToggledByBoolean) { 15 | const visible = adapter.references.instances[key]?.visible || ""; 16 | return component.properties[visible]?.value; 17 | } else if (definitions[key].hidden) { 18 | return false; 19 | } 20 | return true; 21 | } 22 | 23 | export function slotKeysFromDefinitions( 24 | adapter: Adapter, 25 | definitions: SafePropertyDefinitions, 26 | enableInstanceSlots: boolean 27 | ): SlotKeysData { 28 | const slotTextKeys = Object.keys(definitions).filter( 29 | (key) => definitions[key].type === "TEXT" 30 | ); 31 | const hasOneTextProperty = slotTextKeys.length === 1; 32 | const instanceAndTextSlotKeys = Object.keys(definitions).filter( 33 | (key) => 34 | (definitions[key].type === "TEXT" && 35 | adapter.formatters.slotTagFromKey(key)) || 36 | (definitions[key].type === "INSTANCE_SWAP" && !definitions[key].hidden) 37 | ); 38 | 39 | const hasInstanceSlots = Boolean( 40 | instanceAndTextSlotKeys.length && enableInstanceSlots 41 | ); 42 | if ( 43 | hasOneTextProperty && 44 | hasInstanceSlots && 45 | !instanceAndTextSlotKeys.includes(slotTextKeys[0]) 46 | ) { 47 | instanceAndTextSlotKeys.push(slotTextKeys[0]); 48 | } 49 | 50 | return { 51 | slotKeys: hasInstanceSlots 52 | ? instanceAndTextSlotKeys 53 | : hasOneTextProperty 54 | ? slotTextKeys 55 | : [], 56 | slotTextKeys, 57 | hasInstanceSlots, 58 | hasOneTextProperty, 59 | }; 60 | } 61 | 62 | export function formatInstancesInstanceFromComponent( 63 | component: SafeComponent, 64 | adapter: Adapter, 65 | showDefaults: boolean, 66 | explicitBoolean: boolean, 67 | attributeFormatter: ( 68 | property: SafeProperty, 69 | name: string, 70 | explicitBoolean: boolean, 71 | slotTag?: string 72 | ) => string, 73 | tagNameFormatter: (name: string) => string, 74 | slotFormatter: ( 75 | tag: string, 76 | key: string, 77 | slotCount: number, 78 | isDefault?: boolean, 79 | value?: string 80 | ) => string, 81 | options: { 82 | instanceSlot?: boolean; 83 | selfClosing?: boolean; 84 | } = { 85 | instanceSlot: false, 86 | selfClosing: false, 87 | } 88 | ) { 89 | const definitions = adapter.definitions[component.definition]; 90 | const meta = adapter.metas[component.definition]; 91 | 92 | const { slotKeys, slotTextKeys, hasOneTextProperty } = 93 | slotKeysFromComponentAndAdapter( 94 | component, 95 | adapter, 96 | options.instanceSlot || false 97 | ); 98 | 99 | const formatTag = (key: string, tag: string, value: string = "") => 100 | slotFormatter( 101 | tag, 102 | key, 103 | slotKeys.length, 104 | hasOneTextProperty && 105 | key === slotTextKeys[0] && 106 | !adapter.formatters.slotTagFromKey(key), 107 | value 108 | ); 109 | 110 | const slots = slotKeys.reduce<{ [k: string]: string }>((into, key) => { 111 | if (!showDefaults && component.properties[key].default) { 112 | return into; 113 | } 114 | if (definitions[key].type === "TEXT") { 115 | const tag = adapter.formatters.slotTagFromKey(key) || "span"; 116 | into[key] = formatTag( 117 | key, 118 | tag, 119 | component.properties[key].value.toString() 120 | ); 121 | } else if ( 122 | options.instanceSlot && 123 | definitions[key].type === "INSTANCE_SWAP" 124 | ) { 125 | const tag = tagNameFormatter( 126 | figma.getNodeById(component.properties[key].value.toString())?.name || 127 | "" 128 | ); 129 | into[key] = formatTag(key, tag); 130 | } 131 | return into; 132 | }, {}); 133 | 134 | const notTextChildrenKey = (key: string) => !slotKeys.length || !slots[key]; 135 | 136 | const propertyKeys = Object.keys(component.properties).filter( 137 | (key) => 138 | (showDefaults || !component.properties[key].default) && 139 | notTextChildrenKey(key) 140 | ); 141 | 142 | const lines = propertyKeys 143 | .sort() 144 | .map((key: string) => { 145 | if ( 146 | adapter.definitions[component.definition][key].hidden || 147 | !isVisibleKey(key, component, adapter) 148 | ) 149 | return null; 150 | const slotTag = adapter.formatters.slotTagFromKey(key); 151 | return attributeFormatter( 152 | component.properties[key], 153 | key, 154 | explicitBoolean, 155 | slotTag 156 | ); 157 | }) 158 | .filter(Boolean); 159 | const n = tagNameFormatter(meta.name); 160 | const slotValues = Object.values(slots).map((s) => ` ${s}`); 161 | 162 | return !options.selfClosing || slotValues.length 163 | ? `<${n} ${lines.join(" ")}> 164 | ${slotValues.join("\n")} 165 | \n` 166 | : `<${n} ${lines.join(" ")} />`; 167 | 168 | function slotKeysFromComponentAndAdapter( 169 | component: SafeComponent, 170 | adapter: Adapter, 171 | enableInstanceSlots: boolean 172 | ): SlotKeysData { 173 | const definitions = adapter.definitions[component.definition]; 174 | const allTextKeys = Object.keys(component.properties).filter( 175 | (key) => definitions[key].type === "TEXT" 176 | ); 177 | const slotTextKeys = allTextKeys.filter( 178 | (key) => !component.properties[key].undefined 179 | ); 180 | const hasOneTextProperty = allTextKeys.length === 1; 181 | const instanceAndTextSlotKeys = Object.keys(component.properties).filter( 182 | (key) => 183 | !component.properties[key].undefined && 184 | ((definitions[key].type === "TEXT" && 185 | adapter.formatters.slotTagFromKey(key)) || 186 | (definitions[key].type === "INSTANCE_SWAP" && 187 | isVisibleKey(key, component, adapter))) 188 | ); 189 | 190 | const hasInstanceSlots = Boolean( 191 | instanceAndTextSlotKeys.length && enableInstanceSlots 192 | ); 193 | if (hasOneTextProperty && hasInstanceSlots && slotTextKeys[0]) { 194 | instanceAndTextSlotKeys.push(slotTextKeys[0]); 195 | } 196 | return { 197 | slotKeys: hasInstanceSlots 198 | ? instanceAndTextSlotKeys 199 | : hasOneTextProperty 200 | ? slotTextKeys 201 | : [], 202 | slotTextKeys, 203 | hasInstanceSlots, 204 | hasOneTextProperty, 205 | }; 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /plugin-src/formatVue.ts: -------------------------------------------------------------------------------- 1 | import { Adapter } from "./adapter"; 2 | import { 3 | FormatLanguage, 4 | FormatResult, 5 | FormatResultItem, 6 | FormatSettings, 7 | } from "../shared"; 8 | import { 9 | SafeProperty, 10 | SafePropertyDefinition, 11 | SafePropertyDefinitions, 12 | SafePropertyDefinitionMetaMap, 13 | } from "./types"; 14 | import { 15 | formatInstancesInstanceFromComponent, 16 | SlotKeysData, 17 | slotKeysFromDefinitions, 18 | } from "./formatShared"; 19 | import { generateBooleans, generateComments, generateDefaults } from "./config"; 20 | 21 | type TypeDefinitionsObject = { [k: string]: string }; 22 | 23 | export function format( 24 | adapter: Adapter, 25 | settings: FormatSettings 26 | ): FormatResult { 27 | return { 28 | label: "Vue", 29 | items: [ 30 | formatInstances(adapter, settings), 31 | formatDefinitions(adapter, settings), 32 | ], 33 | }; 34 | } 35 | 36 | function formatDefinitions( 37 | adapter: Adapter, 38 | settings: FormatSettings 39 | ): FormatResultItem { 40 | const { definitions, metas } = adapter; 41 | const [isOptionsApi] = settings.options.definitionVue.map((a) => 42 | Boolean(a[1]) 43 | ); 44 | const code: { label?: string; language: FormatLanguage; lines: string[] }[] = 45 | []; 46 | const hasDefinitions = Object.keys(definitions).length; 47 | if (hasDefinitions && !settings.singleNode) { 48 | if (isOptionsApi) { 49 | code.push({ 50 | language: "tsx", 51 | lines: ["import { defineComponent, type PropType } from 'vue'"], 52 | }); 53 | } else { 54 | code.push({ 55 | language: "tsx", 56 | lines: ["import { defineProps, withDefaults } from 'vue'"], 57 | }); 58 | } 59 | } 60 | 61 | Object.keys(definitions).forEach((key) => { 62 | const properties = definitions[key]; 63 | const slotKeysData = slotKeysFromDefinitions(adapter, properties, true); 64 | const comment = settings.singleNode 65 | ? isOptionsApi 66 | ? "import { defineComponent, type PropType } from 'vue'" 67 | : "import { defineProps, withDefaults } from 'vue'" 68 | : ""; 69 | code.push({ 70 | label: isOptionsApi ? "Options API" : "Composition API", 71 | language: "tsx", 72 | lines: [ 73 | comment, 74 | isOptionsApi 75 | ? formatDefinitionsLineForOptionsAPI( 76 | key, 77 | properties, 78 | metas, 79 | slotKeysData 80 | ) 81 | : formatDefinitionsLineForCompositionAPI( 82 | key, 83 | properties, 84 | metas, 85 | slotKeysData 86 | ), 87 | ], 88 | }); 89 | code.push({ 90 | label: "Template", 91 | language: "html", 92 | lines: formatDefinitionsTemplate(key, metas, slotKeysData), 93 | }); 94 | }); 95 | return { 96 | label: settings.singleNode ? "Definition" : "Definitions", 97 | code, 98 | options: settings.options.definitionVue, 99 | optionsKey: "definitionVue", 100 | }; 101 | 102 | function formatDefinitionsLineForOptionsAPI( 103 | key: string, 104 | properties: SafePropertyDefinitions, 105 | metas: SafePropertyDefinitionMetaMap, 106 | { slotKeys }: SlotKeysData 107 | ) { 108 | const types: TypeDefinitionsObject = {}; 109 | const componentName = adapter.formatters.capitalizedNameFromName( 110 | metas[key].name 111 | ); 112 | const propsLines = Object.keys(properties) 113 | .sort() 114 | .map((propName) => 115 | slotKeys.includes(propName) 116 | ? null 117 | : formatDefinitionsOptionsProperties( 118 | componentName, 119 | propName, 120 | types, 121 | properties[propName] 122 | ) 123 | ) 124 | .filter(Boolean); 125 | const propsDefinition = [ 126 | `const props${componentName} = {`, 127 | propsLines.join("\n"), 128 | `}`, 129 | ]; 130 | return [ 131 | generateComments() 132 | ? adapter.formatters.componentJsCommentFromMeta(metas[key]) 133 | : "", 134 | "", 135 | ...Object.keys(types).map((name) => `type ${name} = ${types[name]};`), 136 | "", 137 | propsDefinition.join("\n"), 138 | "", 139 | "defineComponent({", 140 | `name: "${componentName}",`, 141 | `props: props${componentName}`, 142 | `})`, 143 | ].join("\n"); 144 | } 145 | 146 | function formatDefinitionsLineForCompositionAPI( 147 | key: string, 148 | properties: SafePropertyDefinitions, 149 | metas: SafePropertyDefinitionMetaMap, 150 | slotKeysData: SlotKeysData 151 | ) { 152 | const componentName = adapter.formatters.capitalizedNameFromName( 153 | metas[key].name 154 | ); 155 | const interfaceName = `${componentName}Props`; 156 | const types: TypeDefinitionsObject = {}; 157 | const interfaceLines = Object.keys(properties) 158 | .sort() 159 | .map((propName) => 160 | slotKeysData.slotKeys.includes(propName) 161 | ? null 162 | : formatInterfaceProperties( 163 | interfaceName, 164 | propName, 165 | types, 166 | properties[propName] 167 | ) 168 | ) 169 | .filter(Boolean); 170 | return [ 171 | generateComments() 172 | ? adapter.formatters.componentJsCommentFromMeta( 173 | metas[key], 174 | " .vue setup" 175 | ) 176 | : "", 177 | "", 178 | ...Object.keys(types).map((name) => `type ${name} = ${types[name]};`), 179 | "", 180 | `interface ${interfaceName} { ${interfaceLines.join(" ")} }`, 181 | "", 182 | formatComponentPropsFromDefinitionsAndMetas( 183 | key, 184 | properties, 185 | metas, 186 | slotKeysData 187 | ), 188 | "", 189 | ].join("\n"); 190 | } 191 | function formatInterfaceProperties( 192 | interfaceName: string, 193 | propName: string, 194 | types: TypeDefinitionsObject, 195 | definition: SafePropertyDefinition 196 | ) { 197 | const name = adapter.formatters.propertyNameFromKey(propName); 198 | if (definition.hidden) { 199 | return ""; 200 | } 201 | if (definition.type === "BOOLEAN") { 202 | return `${name}?: boolean;`; 203 | } else if (definition.type === "NUMBER") { 204 | return `${name}?: number;`; 205 | } else if (definition.type === "TEXT") { 206 | return `${name}?: string;`; 207 | } else if (definition.type === "VARIANT") { 208 | const n = `${interfaceName}${adapter.formatters.capitalizedNameFromName( 209 | propName 210 | )}`; 211 | const value = (definition.variantOptions || []) 212 | .map((o) => `'${o}'`) 213 | .join(" | "); 214 | types[n] = value; 215 | return `${name}?: ${n};`; 216 | } else if (definition.type === "EXPLICIT") { 217 | return `${name}?: "${definition.defaultValue}";`; 218 | } else if (definition.type === "INSTANCE_SWAP") { 219 | return `${name}?: Component;`; 220 | } else { 221 | return `${name}?: ${JSON.stringify(definition)};`; 222 | } 223 | } 224 | 225 | // https://vuejs.org/guide/components/slots.html#named-slots 226 | function formatDefinitionsTemplate( 227 | key: string, 228 | metas: SafePropertyDefinitionMetaMap, 229 | { slotKeys, slotTextKeys, hasOneTextProperty }: SlotKeysData 230 | ) { 231 | const meta = metas[key]; 232 | const template = slotKeys.map((key) => 233 | hasOneTextProperty && key === slotTextKeys[0] 234 | ? ` ` 235 | : ` ` 238 | ); 239 | 240 | return [ 241 | !generateComments() || settings.singleNode 242 | ? "" 243 | : ``, 246 | "\n", 247 | `
`, 250 | ...template, 251 | "
", 252 | ]; 253 | } 254 | 255 | function formatDefinitionsOptionsProperties( 256 | componentName: string, 257 | propName: string, 258 | types: TypeDefinitionsObject, 259 | definition: SafePropertyDefinition 260 | ) { 261 | const { name, type, defaultValue, optional } = definition; 262 | if (definition.hidden) { 263 | return ""; 264 | } 265 | const clean = adapter.formatters.propertyNameFromKey(name); 266 | if (type === "BOOLEAN") { 267 | return `${clean}: { 268 | type: Boolean, 269 | default: ${defaultValue}, 270 | },`; 271 | } else if (type === "INSTANCE_SWAP") { 272 | const node = figma.getNodeById(defaultValue); 273 | const value = node 274 | ? node.name === settings.valueOptional 275 | ? "" 276 | : `default: "${adapter.formatters.capitalizedNameFromName( 277 | node.name 278 | )}";` 279 | : `default: "${defaultValue}"`; 280 | return `${clean}: { 281 | type: ${node ? "Object" : "String"}, 282 | ${value} 283 | },`; 284 | } else if (type === "NUMBER") { 285 | return `${clean}: { 286 | type: Number, 287 | default: ${defaultValue}, 288 | },`; 289 | } else if (type === "VARIANT") { 290 | const n = `${componentName}${adapter.formatters.capitalizedNameFromName( 291 | propName 292 | )}`; 293 | const value = (definition.variantOptions || []) 294 | .map((o) => `'${o}'`) 295 | .join(" | "); 296 | types[n] = value; 297 | return `${clean}: { 298 | type: Object as PropType<${n}>, 299 | ${ 300 | optional && defaultValue === settings.valueOptional 301 | ? "" 302 | : `default: "${defaultValue}",` 303 | } 304 | },`; 305 | } else { 306 | return `${clean}: { 307 | type: String, 308 | default: "${defaultValue}", 309 | },`; 310 | } 311 | } 312 | 313 | function formatComponentPropsFromDefinitionsAndMetas( 314 | key: string, 315 | definitions: SafePropertyDefinitions, 316 | metas: SafePropertyDefinitionMetaMap, 317 | { slotKeys }: SlotKeysData 318 | ): string { 319 | const meta = metas[key]; 320 | const keys = Object.keys(definitions).sort(); 321 | const propsName = `${adapter.formatters.capitalizedNameFromName( 322 | meta.name 323 | )}Props`; 324 | return `withDefaults(defineProps<${propsName}>(), { 325 | ${keys 326 | .map((key) => 327 | slotKeys.includes(key) 328 | ? null 329 | : formatDefinitionInputProperty(definitions[key]) 330 | ) 331 | .filter(Boolean) 332 | .join("\n")} 333 | })`; 334 | } 335 | 336 | function formatDefinitionInputProperty( 337 | definition: SafePropertyDefinition 338 | ): string { 339 | const { name, type, defaultValue } = definition; 340 | const clean = adapter.formatters.propertyNameFromKey(name); 341 | if (definition.hidden) { 342 | return ""; 343 | } 344 | if (definition.optional && defaultValue === settings.valueOptional) { 345 | return `${clean},`; 346 | } 347 | if (type === "BOOLEAN") { 348 | return `${clean}: ${defaultValue},`; 349 | } else if (type === "INSTANCE_SWAP") { 350 | const node = figma.getNodeById(defaultValue); 351 | if (definition.optional && node?.name === settings.valueOptional) { 352 | return `${clean},`; 353 | } 354 | return node 355 | ? `${clean}: <${adapter.formatters.capitalizedNameFromName( 356 | node.name 357 | )} />,` 358 | : `${clean}: "${defaultValue}",`; 359 | } else if (type === "NUMBER") { 360 | return `${clean}: ${defaultValue},`; 361 | } else if (type === "VARIANT") { 362 | return `${clean}: "${defaultValue}",`; 363 | } else { 364 | return `${clean}: "${defaultValue}",`; 365 | } 366 | } 367 | } 368 | 369 | function formatInstances( 370 | adapter: Adapter, 371 | settings: FormatSettings 372 | ): FormatResultItem { 373 | let [showDefaults, explicitBoolean] = settings.options.instance.map((a) => 374 | Boolean(a[1]) 375 | ); 376 | showDefaults = 377 | generateDefaults() === null ? showDefaults : Boolean(generateDefaults()); 378 | explicitBoolean = 379 | generateDefaults() === null ? explicitBoolean : Boolean(generateBooleans()); 380 | const { components } = adapter; 381 | const lines = [ 382 | Object.values(components) 383 | .map((component) => 384 | formatInstancesInstanceFromComponent( 385 | component, 386 | adapter, 387 | showDefaults, 388 | explicitBoolean, 389 | formatInstancesAttributeFromProperty, 390 | adapter.formatters.capitalizedNameFromName, 391 | slotFormatter, 392 | { 393 | selfClosing: true, 394 | instanceSlot: true, 395 | } 396 | ) 397 | ) 398 | .join("\n\n"), 399 | ]; 400 | return { 401 | label: settings.singleNode ? "Instance" : "Instances", 402 | code: [ 403 | { 404 | language: "vue", 405 | lines, 406 | }, 407 | ], 408 | options: settings.options.instance, 409 | optionsKey: "instance", 410 | }; 411 | 412 | function slotFormatter( 413 | tag: string, 414 | key: string, 415 | slotCount: number, 416 | isDefault = false, 417 | value: string = "" 418 | ) { 419 | const tagged = value ? `<${tag}>${value}` : `<${tag} />`; 420 | if (slotCount > 1 && !isDefault) { 421 | return ``; 424 | } 425 | return isDefault ? value : tagged; 426 | } 427 | 428 | function formatInstancesAttributeFromProperty( 429 | property: SafeProperty, 430 | name: string, 431 | explicitBoolean: boolean 432 | ) { 433 | const clean = adapter.formatters.propertyNameFromKey(name); 434 | if (property.undefined) { 435 | return ""; 436 | } 437 | if (property.type === "BOOLEAN") { 438 | return explicitBoolean 439 | ? `:${clean}="${property.value}"` 440 | : property.value 441 | ? clean 442 | : ""; 443 | } else if (property.type === "NUMBER") { 444 | return `:${clean}="${property.value}"`; 445 | } else if (property.type === "INSTANCE_SWAP") { 446 | const node = figma.getNodeById(property.value); 447 | return node 448 | ? `:${clean}="${adapter.formatters.capitalizedNameFromName(node.name)}"` 449 | : `:${clean}="${property.value}"`; 450 | } else { 451 | return `${clean}="${property.value}"`; 452 | } 453 | } 454 | } 455 | -------------------------------------------------------------------------------- /plugin-src/formatWebComponents.ts: -------------------------------------------------------------------------------- 1 | import { Adapter } from "./adapter"; 2 | import { 3 | FormatLanguage, 4 | FormatResult, 5 | FormatResultItem, 6 | FormatSettings, 7 | } from "../shared"; 8 | import { 9 | SafeProperty, 10 | SafePropertyDefinition, 11 | SafePropertyDefinitionMetaMap, 12 | SafePropertyDefinitions, 13 | } from "./types"; 14 | import { 15 | formatInstancesInstanceFromComponent, 16 | SlotKeysData, 17 | slotKeysFromDefinitions, 18 | } from "./formatShared"; 19 | import { generateBooleans, generateComments, generateDefaults } from "./config"; 20 | 21 | export function format( 22 | adapter: Adapter, 23 | settings: FormatSettings 24 | ): FormatResult { 25 | return { 26 | label: "Web Components", 27 | items: [ 28 | formatInstances(adapter, settings), 29 | formatDefinitions(adapter, settings), 30 | ], 31 | }; 32 | } 33 | 34 | function formatDefinitions( 35 | adapter: Adapter, 36 | settings: FormatSettings 37 | ): FormatResultItem { 38 | const { definitions, metas } = adapter; 39 | const code: { label?: string; language: FormatLanguage; lines: string[] }[] = 40 | []; 41 | Object.entries(definitions).forEach(([key, definition]) => { 42 | const slotKeysData = slotKeysFromDefinitions(adapter, definition, true); 43 | code.push({ 44 | label: "Class", 45 | language: "ts", 46 | lines: [ 47 | generateComments() 48 | ? adapter.formatters.componentJsCommentFromMeta(metas[key]) 49 | : "", 50 | formatDefinitionsVariantOptionTypes(metas[key].name, definition).join( 51 | "\n" 52 | ), 53 | ...formatDefinitionsComponentClass( 54 | key, 55 | definition, 56 | metas, 57 | slotKeysData 58 | ), 59 | ], 60 | }); 61 | code.push({ 62 | label: "Template", 63 | language: "html", 64 | lines: [ 65 | !generateComments() || settings.singleNode 66 | ? "" 67 | : ``, 70 | "\n", 71 | ...formatDefinitionsTemplate(key, metas, slotKeysData), 72 | ], 73 | }); 74 | }); 75 | return { 76 | label: settings.singleNode ? "Definition" : "Definitions", 77 | code, 78 | options: [], 79 | }; 80 | 81 | // https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_templates_and_slots 82 | function formatDefinitionsTemplate( 83 | key: string, 84 | metas: SafePropertyDefinitionMetaMap, 85 | { slotKeys, slotTextKeys, hasOneTextProperty }: SlotKeysData 86 | ): string[] { 87 | const meta = metas[key]; 88 | const hyphenatedName = adapter.formatters.hyphenatedNameFromName(meta.name); 89 | const templateContent = slotKeys.map((key) => 90 | hasOneTextProperty && key === slotTextKeys[0] 91 | ? ` ` 92 | : ` ` 95 | ); 96 | return [ 97 | ``, 100 | ]; 101 | } 102 | 103 | function formatDefinitionsComponentClass( 104 | key: string, 105 | definitions: SafePropertyDefinitions, 106 | metas: SafePropertyDefinitionMetaMap, 107 | { slotKeys }: SlotKeysData 108 | ): string[] { 109 | const meta = metas[key]; 110 | const keys = Object.keys(definitions).sort(); 111 | const capitalizedName = adapter.formatters.capitalizedNameFromName( 112 | meta.name 113 | ); 114 | const hyphenatedName = adapter.formatters.hyphenatedNameFromName(meta.name); 115 | return [ 116 | `class ${capitalizedName} extends HTMLElement {`, 117 | keys 118 | .map((key) => 119 | definitions[key].hidden || slotKeys.includes(key) 120 | ? null 121 | : formatDefinitionsInputProperty(meta.name, definitions[key]) 122 | ) 123 | .filter(Boolean) 124 | .join("\n"), 125 | `constructor() {`, 126 | `super();`, 127 | `this.attachShadow({ mode: "open" }).appendChild(document.getElementById("template-${hyphenatedName}").content.cloneNode(true));`, 128 | "}", 129 | `public static get observedAttributes(): string[] { 130 | return [${keys 131 | .filter((key) => !definitions[key].hidden && !slotKeys.includes(key)) 132 | .map((key) => `'${adapter.formatters.propertyNameFromKey(key)}'`) 133 | .join(", ")}]; 134 | }`, 135 | "}", 136 | `customElements.define("${hyphenatedName}", ${capitalizedName});`, 137 | ]; 138 | } 139 | 140 | function formatDefinitionsInputProperty( 141 | componentName: string, 142 | definition: SafePropertyDefinition 143 | ): string { 144 | const { name, type, defaultValue, optional } = definition; 145 | const clean = adapter.formatters.propertyNameFromKey(name); 146 | if (type === "BOOLEAN") { 147 | return `public ${clean}?: boolean = ${defaultValue};`; 148 | } else if (type === "INSTANCE_SWAP") { 149 | const node = figma.getNodeById(defaultValue); 150 | const value = node 151 | ? node.name === settings.valueOptional 152 | ? "" 153 | : ` = "${adapter.formatters.capitalizedNameFromName(node.name)}";` 154 | : ` = "${defaultValue}"`; 155 | return node 156 | ? `public ${clean}?: HTMLElement${value};` 157 | : `public ${clean}?: string${value};`; 158 | } else if (type === "NUMBER") { 159 | return `public ${clean}?: number = ${defaultValue};`; 160 | } else if (type === "VARIANT") { 161 | return `public ${clean}?: ${typeNameForComponentProperty( 162 | componentName, 163 | name 164 | )}${ 165 | optional && defaultValue === settings.valueOptional 166 | ? "" 167 | : ` = "${defaultValue}";` 168 | }`; 169 | } else { 170 | return `public ${clean}?: string = "${defaultValue}";`; 171 | } 172 | } 173 | 174 | function formatDefinitionsVariantOptionTypes( 175 | componentName: string, 176 | definitions: SafePropertyDefinitions 177 | ): string[] { 178 | const types: string[] = []; 179 | Object.entries(definitions).forEach(([key, definition]) => { 180 | if (definition.type === "VARIANT" && !definition.hidden) { 181 | types.push( 182 | `type ${typeNameForComponentProperty( 183 | componentName, 184 | definition.name 185 | )} = ${definition.variantOptions.map((o) => `'${o}'`).join(" | ")}` 186 | ); 187 | } 188 | }); 189 | return types; 190 | } 191 | 192 | function typeNameForComponentProperty(componentName: string, name: string) { 193 | return `${adapter.formatters.capitalizedNameFromName( 194 | componentName 195 | )}${adapter.formatters.capitalizedNameFromName(name)}`; 196 | } 197 | } 198 | 199 | function formatInstances( 200 | adapter: Adapter, 201 | settings: FormatSettings 202 | ): FormatResultItem { 203 | let [showDefaults, explicitBoolean] = settings.options.instance.map((a) => 204 | Boolean(a[1]) 205 | ); 206 | showDefaults = 207 | generateDefaults() === null ? showDefaults : Boolean(generateDefaults()); 208 | explicitBoolean = 209 | generateDefaults() === null ? explicitBoolean : Boolean(generateBooleans()); 210 | const { components } = adapter; 211 | const lines: string[] = []; 212 | Object.values(components).forEach((component) => 213 | lines.push( 214 | formatInstancesInstanceFromComponent( 215 | component, 216 | adapter, 217 | showDefaults, 218 | explicitBoolean, 219 | formatInstancesAttributeFromProperty, 220 | adapter.formatters.hyphenatedNameFromName, 221 | slotFormatter, 222 | { 223 | instanceSlot: true, 224 | } 225 | ) 226 | ) 227 | ); 228 | return { 229 | label: settings.singleNode ? "Instance" : "Instances", 230 | code: [ 231 | { 232 | language: "html", 233 | lines, 234 | }, 235 | ], 236 | options: settings.options.instance, 237 | optionsKey: "instance", 238 | }; 239 | 240 | function slotFormatter( 241 | tag: string, 242 | key: string, 243 | _slotCount: number, 244 | _isDefault = false, 245 | value: string = "" 246 | ) { 247 | return `<${tag} slot="${adapter.formatters.propertyNameFromKey( 248 | key 249 | )}">${value}`; 250 | } 251 | 252 | function formatInstancesAttributeFromProperty( 253 | property: SafeProperty, 254 | name: string, 255 | explicitBoolean: boolean 256 | ) { 257 | if (property.undefined) { 258 | return ""; 259 | } 260 | const clean = adapter.formatters.propertyNameFromKey(name); 261 | if (property.type === "BOOLEAN") { 262 | return explicitBoolean 263 | ? `${clean}="${property.value}"` 264 | : property.value 265 | ? `${clean}` 266 | : ""; 267 | } else if (property.type === "INSTANCE_SWAP") { 268 | const node = figma.getNodeById(property.value); 269 | return node 270 | ? `${clean}="${adapter.formatters.capitalizedNameFromName(node.name)}"` 271 | : `${clean}="${property.value}"`; 272 | } else { 273 | return `${clean}="${property.value}"`; 274 | } 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /plugin-src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "lib": ["es2017"], 5 | "strict": true, 6 | "typeRoots": ["../node_modules/@figma",], 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /plugin-src/types.ts: -------------------------------------------------------------------------------- 1 | export type SafePropertyReferencesMap = { 2 | instances: { 3 | [k: string]: { visible?: string; characters?: string }; 4 | }; 5 | characterNodes: { [k: string]: string }; 6 | visibleNodes: { [k: string]: string }; 7 | properties: { 8 | [k: string]: { 9 | visibleProperties?: { [k: string]: true }; 10 | visibleNodes?: { [k: string]: true }; 11 | characterNodes?: { [k: string]: true }; 12 | }; 13 | }; 14 | }; 15 | 16 | export type SafePropertyDefinitionMeta = { 17 | name: string; 18 | id: string; 19 | description?: string; 20 | documentationLinks: string[]; 21 | }; 22 | 23 | export type SafePropertyDefinitionMetaMap = { 24 | [k: string]: SafePropertyDefinitionMeta; 25 | }; 26 | 27 | export type SafeComponentMap = { 28 | [k: string]: SafeComponent; 29 | }; 30 | 31 | export type SafePropertyDefinitionsMap = { 32 | [k: string]: SafePropertyDefinitions; 33 | }; 34 | 35 | export type SafePropertyDefinitions = { 36 | [k: string]: SafePropertyDefinition; 37 | }; 38 | 39 | export type SafeProperties = { 40 | [k: string]: SafeProperty; 41 | }; 42 | 43 | export type SafeType = 44 | | "BOOLEAN" 45 | | "EXPLICIT" 46 | | "INSTANCE_SWAP" 47 | | "NUMBER" 48 | | "TEXT" 49 | | "VARIANT"; 50 | 51 | interface SafePropertyDefinitionBoolean { 52 | type: Extract; 53 | defaultValue: boolean; 54 | } 55 | interface SafePropertyDefinitionExplicit { 56 | type: Extract; 57 | defaultValue: string | number | boolean; 58 | } 59 | interface SafePropertyDefinitionInstanceSwap { 60 | type: Extract; 61 | defaultValue: string; 62 | instanceOptions: InstanceSwapPreferredValue[]; 63 | } 64 | interface SafePropertyDefinitionNumber { 65 | type: Extract; 66 | defaultValue: number; 67 | } 68 | interface SafePropertyDefinitionText { 69 | type: Extract; 70 | defaultValue: string; 71 | } 72 | interface SafePropertyDefinitionVariant { 73 | type: Extract; 74 | defaultValue: string | number; 75 | variantOptions: string[] | number[]; 76 | } 77 | 78 | interface SafePropertyBoolean { 79 | type: Extract; 80 | value: boolean; 81 | } 82 | interface SafePropertyExplicit { 83 | type: Extract; 84 | value: string | number | boolean; 85 | } 86 | interface SafePropertyNumber { 87 | type: Extract; 88 | value: number; 89 | } 90 | interface SafePropertyString { 91 | type: Extract; 92 | value: string; 93 | } 94 | interface SafePropertyVariant { 95 | type: Extract; 96 | value: string | number; 97 | } 98 | 99 | export type SafePropertyDefinition = { 100 | name: string; 101 | optional?: boolean; 102 | hidden?: boolean; 103 | } & ( 104 | | SafePropertyDefinitionBoolean 105 | | SafePropertyDefinitionExplicit 106 | | SafePropertyDefinitionInstanceSwap 107 | | SafePropertyDefinitionNumber 108 | | SafePropertyDefinitionText 109 | | SafePropertyDefinitionVariant 110 | ); 111 | 112 | export type SafeProperty = { 113 | name: string; 114 | default: boolean; 115 | undefined?: boolean; 116 | } & ( 117 | | SafePropertyBoolean 118 | | SafePropertyExplicit 119 | | SafePropertyNumber 120 | | SafePropertyString 121 | | SafePropertyVariant 122 | ); 123 | 124 | export interface SafeComponent { 125 | id: string; 126 | name: string; 127 | definition: string; 128 | properties: SafeProperties; 129 | } 130 | -------------------------------------------------------------------------------- /plugin-src/utils.ts: -------------------------------------------------------------------------------- 1 | export function asBoolean(string: string) { 2 | return { false: false, true: true }[string.toLowerCase()] === true; 3 | } 4 | 5 | export function isBoolean(string: string) { 6 | return ["false", "true"].includes(string.toLowerCase()); 7 | } 8 | 9 | export function asNumber(string: string) { 10 | return parseFloat(string); 11 | } 12 | 13 | export function isNumber(string: string) { 14 | return Boolean(string.match(/^\d*\.?\d+$/)); 15 | } 16 | 17 | export type RelevantComponentNode = 18 | | InstanceNode 19 | | ComponentNode 20 | | ComponentSetNode; 21 | 22 | // Filtering nodes to instances and components that are not variant comonents 23 | export function componentNodesFromSceneNodes( 24 | nodes: SceneNode[] 25 | ): RelevantComponentNode[] { 26 | return nodes 27 | .filter( 28 | (n) => 29 | n.type === "INSTANCE" || 30 | n.type === "COMPONENT_SET" || 31 | (n.type === "COMPONENT" && n.parent?.type !== "COMPONENT_SET") 32 | ) 33 | .map((n) => { 34 | switch (n.type) { 35 | case "INSTANCE": 36 | return n as InstanceNode; 37 | case "COMPONENT_SET": 38 | return n as ComponentSetNode; 39 | case "COMPONENT": 40 | default: 41 | return n as ComponentNode; 42 | } 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /shared.ts: -------------------------------------------------------------------------------- 1 | export type FormatSettingsOptions = [string | [string, string], 0 | 1][]; 2 | export type FormatSettingsScale = "sm" | "md" | "lg"; 3 | export type FormatSettings = { 4 | version: string; 5 | options: { [k: string]: FormatSettingsOptions }; 6 | singleNode?: boolean; 7 | tab?: string; 8 | tabIndex?: number; 9 | prefixIgnore: string; 10 | scale: FormatSettingsScale; 11 | suffixSlot: string; 12 | valueOptional: string; 13 | }; 14 | 15 | export type PluginMessageType = "RESULT" | "CONFIG" | "FORMAT"; 16 | 17 | interface PluginMessageResult { 18 | type: Extract; 19 | results: FormatResult[]; 20 | settings: FormatSettings; 21 | } 22 | interface PluginMessageFormat { 23 | type: Extract; 24 | language: FormatLanguage; 25 | lines: string[]; 26 | index: number; 27 | } 28 | 29 | interface PluginMessageConfig { 30 | type: Extract; 31 | settings: FormatSettings; 32 | codegen?: boolean; 33 | } 34 | 35 | export type PluginMessage = 36 | | PluginMessageResult 37 | | PluginMessageConfig 38 | | PluginMessageFormat; 39 | 40 | export type FormatLanguage = 41 | | "angular" 42 | | "ts" 43 | | "tsx" 44 | | "jsx" 45 | | "json" 46 | | "html" 47 | | "vue"; 48 | 49 | export interface FormatResult { 50 | label: string; 51 | items: FormatResultItem[]; 52 | } 53 | export interface FormatResultItemCode { 54 | label?: string; 55 | language: FormatLanguage; 56 | lines: string[]; 57 | } 58 | export interface FormatResultItem { 59 | label: string; 60 | code: FormatResultItemCode[]; 61 | options: FormatSettingsOptions; 62 | optionsKey?: string; 63 | } 64 | -------------------------------------------------------------------------------- /ui-src/App.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-bg: var(--figma-color-bg); 3 | --color-bg-hover: var(--figma-color-bg-hover); 4 | --color-bg-active: var(--figma-color-bg-pressed); 5 | --color-border: var(--figma-color-border); 6 | --color-border-focus: var(--figma-color-border-selected); 7 | --color-icon: var(--figma-color-icon); 8 | --color-text: var(--figma-color-text); 9 | --color-bg-brand: var(--figma-color-bg-brand); 10 | --color-bg-brand-hover: var(--figma-color-bg-brand-hover); 11 | --color-bg-brand-active: var(--figma-color-bg-brand-pressed); 12 | --color-border-brand: var(--figma-color-border-brand); 13 | --color-border-brand-focus: var(--figma-color-border-selected-strong); 14 | --color-text-brand: var(--figma-color-text-onbrand); 15 | } 16 | 17 | html, 18 | body { 19 | height: 100%; 20 | } 21 | 22 | body, 23 | input, 24 | button { 25 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, 26 | Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; 27 | font-size: 12px; 28 | } 29 | pre { 30 | font-size: 12px; 31 | } 32 | 33 | body { 34 | background: var(--color-bg); 35 | color: var(--color-text); 36 | margin: 0; 37 | } 38 | 39 | header { 40 | background-color: var(--color-bg); 41 | border-bottom: 1px solid var(--color-border); 42 | display: flex; 43 | justify-content: space-between; 44 | padding: 1rem; 45 | position: sticky; 46 | top: 0; 47 | width: 100%; 48 | } 49 | pre { 50 | overflow: auto; 51 | } 52 | pre + pre { 53 | border-top: 1px solid var(--color-border); 54 | } 55 | main, 56 | header { 57 | box-sizing: border-box; 58 | } 59 | main.padded { 60 | padding: 1rem; 61 | } 62 | 63 | header > div { 64 | align-items: center; 65 | display: flex; 66 | gap: 0.25rem; 67 | } 68 | 69 | main h2 { 70 | margin-bottom: 0.5rem; 71 | } 72 | main > div + div { 73 | margin-top: 1.5rem; 74 | } 75 | main input:not([type="checkbox"]) { 76 | display: block; 77 | width: 100%; 78 | } 79 | 80 | main div.row { 81 | display: flex; 82 | gap: 1rem; 83 | } 84 | 85 | h1, 86 | h2, 87 | h3 { 88 | margin: 0; 89 | } 90 | 91 | button, 92 | select, 93 | input { 94 | border-radius: 0.25rem; 95 | background: var(--color-bg); 96 | color: var(--color-text); 97 | cursor: pointer; 98 | border: 1px solid var(--color-border); 99 | padding: 0.5rem; 100 | } 101 | button.small { 102 | font-size: 0.8em; 103 | padding: 0.25rem; 104 | } 105 | button:hover, 106 | select:hover { 107 | background-color: var(--color-bg-hover); 108 | } 109 | button:active, 110 | select:active { 111 | background-color: var(--color-bg-active); 112 | } 113 | button:focus-visible { 114 | border: none; 115 | outline-color: var(--color-border-focus); 116 | } 117 | select:focus-visible { 118 | outline-color: var(--color-border-focus); 119 | } 120 | 121 | button.brand { 122 | --color-bg: var(--color-bg-brand); 123 | --color-text: var(--color-text-brand); 124 | --color-bg-hover: var(--color-bg-brand-hover); 125 | --color-bg-active: var(--color-bg-brand-active); 126 | --color-border: transparent; 127 | --color-border-focus: var(--color-border-brand-focus); 128 | } 129 | 130 | input { 131 | background: 1px solid var(--color-bg); 132 | border: 1px solid var(--color-border); 133 | color: 1px solid var(--color-text); 134 | padding: 0.5rem; 135 | } 136 | -------------------------------------------------------------------------------- /ui-src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; 3 | import { 4 | FormatResult, 5 | FormatResultItemCode, 6 | FormatSettings, 7 | FormatSettingsOptions, 8 | FormatSettingsScale, 9 | PluginMessage, 10 | PluginMessageType, 11 | } from "../shared"; 12 | import { 13 | tomorrow as themeDark, 14 | base16AteliersulphurpoolLight as themeLight, 15 | } from "react-syntax-highlighter/dist/esm/styles/prism"; 16 | import prettier from "prettier/esm/standalone.mjs"; 17 | import parserBabel from "prettier/esm/parser-babel.mjs"; 18 | import parserHTMLCustom from "./parser-html-custom"; 19 | import "./App.css"; 20 | 21 | const joiner = (items: string[]) => items.join("\n\n"); 22 | 23 | function formatCode( 24 | { language, lines }: FormatResultItemCode, 25 | scale: FormatSettingsScale 26 | ) { 27 | const printWidthForScale = (scale: FormatSettingsScale) => 28 | scale === "sm" ? 60 : scale === "md" ? 50 : 30; 29 | 30 | const prettierOptionsTS = (scale: FormatSettingsScale) => ({ 31 | printWidth: printWidthForScale(scale), 32 | parser: "babel-ts", 33 | plugins: [parserBabel], 34 | semi: true, 35 | }); 36 | const prettierOptionsHTML = (scale: FormatSettingsScale) => ({ 37 | printWidth: printWidthForScale(scale), 38 | parser: "html", 39 | plugins: [parserHTMLCustom], 40 | htmlWhitespaceSensitivity: "ignore", 41 | bracketSameLine: false, 42 | }); 43 | switch (language) { 44 | case "html": 45 | return prettier.format(lines.join("\n"), prettierOptionsHTML(scale)); 46 | 47 | case "vue": 48 | return prettier.format(lines.join("\n"), { 49 | ...prettierOptionsHTML(scale), 50 | }); 51 | 52 | case "angular": 53 | return prettier.format(lines.join("\n"), { 54 | ...prettierOptionsHTML(scale), 55 | parser: "angular", 56 | }); 57 | 58 | case "json": 59 | return lines.join("\n"); 60 | case "jsx": 61 | return lines 62 | .map((line) => 63 | prettier.format(line, prettierOptionsTS(scale)).replace(/;\n$/, "") 64 | ) 65 | .join("\n\n"); 66 | case "ts": 67 | case "tsx": 68 | const tsString = joiner(lines); 69 | return prettier.format(tsString, prettierOptionsTS(scale)); 70 | } 71 | } 72 | 73 | const detectLightMode = () => 74 | document.documentElement.classList.contains("figma-light"); 75 | 76 | function App() { 77 | const [mode, setMode] = useState("RESULT"); 78 | const [settings, setSettings] = useState(); 79 | const [codegen, setCodegen] = useState(); 80 | const [resultsMap, setResultsMap] = useState<{ 81 | [k: string]: FormatResult; 82 | }>({}); 83 | const [scale, setScale] = useState("sm"); 84 | const [tab, setTab] = useState(); 85 | const [tabIndex, setTabIndex] = useState(0); 86 | const [theme, setTheme] = useState<{ [key: string]: React.CSSProperties }>( 87 | detectLightMode() ? themeLight : themeDark 88 | ); 89 | 90 | useEffect(() => { 91 | window.onmessage = ({ 92 | data: { pluginMessage }, 93 | }: { 94 | data: { pluginMessage: PluginMessage }; 95 | }) => { 96 | if (pluginMessage.type === "RESULT") { 97 | const map = { ...resultsMap }; 98 | pluginMessage.results.forEach((result) => { 99 | map[result.label] = { 100 | label: result.label, 101 | items: result.items.map((item) => ({ 102 | code: [ 103 | ...item.code.map(({ language, lines }) => ({ 104 | language, 105 | lines: [...lines], 106 | })), 107 | ], 108 | label: item.label, 109 | options: [...item.options], 110 | optionsKey: item.optionsKey, 111 | })), 112 | }; 113 | }); 114 | setResultsMap(map); 115 | const { settings } = pluginMessage; 116 | if (!tab) { 117 | const initialTab = 118 | settings.tab && settings.tab in map 119 | ? settings.tab 120 | : Object.values(map)[0]?.label || ""; 121 | handleTabChange(initialTab, settings.tabIndex); 122 | } 123 | setScale(settings.scale); 124 | } else if (pluginMessage.type === "CONFIG") { 125 | setMode("CONFIG"); 126 | setSettings(pluginMessage.settings); 127 | setCodegen(Boolean(pluginMessage.codegen)); 128 | } else if (pluginMessage.type === "FORMAT") { 129 | const result = formatCode( 130 | { language: pluginMessage.language, lines: pluginMessage.lines }, 131 | "md" 132 | ); 133 | parent.postMessage( 134 | { 135 | pluginMessage: { 136 | index: pluginMessage.index, 137 | result, 138 | type: "FORMAT_RESULT", 139 | }, 140 | }, 141 | "*" 142 | ); 143 | } 144 | }; 145 | 146 | const stylesheet = document.getElementById( 147 | "figma-style" 148 | ) as HTMLStyleElement; 149 | 150 | if (stylesheet) { 151 | setTheme(detectLightMode() ? themeLight : themeDark); 152 | const observer = new MutationObserver(() => { 153 | setTheme(detectLightMode() ? themeLight : themeDark); 154 | }); 155 | observer.observe(stylesheet, { childList: true }); 156 | } 157 | }, [tab, resultsMap, handleTabChange, setTheme]); 158 | 159 | function handleTabChange(s: string, index?: number) { 160 | setTab(s); 161 | if (index !== undefined) { 162 | handleTabIndexChange(index, s); 163 | } else if (!Boolean(resultsMap[s]?.items[tabIndex])) { 164 | handleTabIndexChange(0, s); 165 | } else { 166 | sendOptions({ tab: s }); 167 | } 168 | } 169 | 170 | function handleTabIndexChange(i: number, t?: string) { 171 | setTabIndex(i); 172 | sendOptions({ tab: t || tab, tabIndex: i }); 173 | } 174 | 175 | const result = tab ? resultsMap[tab] : null; 176 | const resultItem = result ? result?.items[tabIndex] : null; 177 | 178 | function sendOptions( 179 | overrides: { 180 | options?: FormatSettingsOptions; 181 | tab?: string; 182 | tabIndex?: number; 183 | } = {} 184 | ) { 185 | const pluginMessage = { 186 | type: "OPTIONS", 187 | optionsKey: resultItem?.optionsKey, 188 | options: overrides.options || resultItem?.options, 189 | tab: overrides.tab || tab, 190 | tabIndex: 191 | overrides.tabIndex === undefined ? tabIndex : overrides.tabIndex, 192 | }; 193 | parent.postMessage({ pluginMessage }, "*"); 194 | } 195 | 196 | function updateSettings(settings: FormatSettings) { 197 | setSettings(settings); 198 | const pluginMessage = { 199 | type: "SETTINGS", 200 | settings, 201 | }; 202 | parent.postMessage({ pluginMessage }, "*"); 203 | } 204 | 205 | function renderedResult() { 206 | if (!resultItem) return null; 207 | 208 | return resultItem.code.map(({ language, lines }, i) => ( 209 | 221 | {formatCode({ language, lines }, scale)} 222 | 223 | )); 224 | } 225 | 226 | function renderResults() { 227 | return ( 228 | <> 229 |
230 | {tab ? ( 231 |
232 | {Object.keys(resultsMap).length > 1 ? ( 233 | 240 | ) : ( 241 |

{tab}

242 | )} 243 | {result ? ( 244 | 255 | ) : null} 256 |
257 | ) : null} 258 | {resultItem ? ( 259 |
260 | {resultItem.options.map(([label, value], i) => ( 261 | 273 | ))} 274 |
275 | ) : null} 276 |
277 |
282 | {renderedResult()} 283 |
284 | 285 | ); 286 | } 287 | 288 | function renderConfig() { 289 | return settings ? ( 290 |
291 |
292 |

Ignored Property Prefix

293 |

294 | If present, properties with this prefix will be ignored from code 295 | generation. 296 |

297 | 301 | updateSettings({ 302 | ...settings, 303 | prefixIgnore: e.currentTarget.value.trim(), 304 | }) 305 | } 306 | placeholder="Your prefix here" 307 | /> 308 |
309 |
310 |

Text Property Slot Suffix

311 |

312 | If present, text properties named with this suffix will be treated 313 | as a <span> slot. Appending{" "} 314 | [tagname] to this suffix in the text property name will 315 | control which html tag is used, eg{" "} 316 | {settings.suffixSlot || "YOUR-SUFFIX"}[h1]. 317 |

318 | 322 | updateSettings({ 323 | ...settings, 324 | suffixSlot: e.currentTarget.value.trim(), 325 | }) 326 | } 327 | placeholder="Your slot suffix here" 328 | /> 329 |
330 |
331 |

Optional Variant and Instance Default Name

332 |

333 | If present, variant properties with a default of{" "} 334 | {settings.valueOptional ? ( 335 | "{settings.valueOptional}" 336 | ) : ( 337 | "this value" 338 | )}{" "} 339 | and instance swap properties with a default instance named{" "} 340 | {settings.valueOptional ? ( 341 | "{settings.valueOptional}" 342 | ) : ( 343 | "this value" 344 | )}{" "} 345 | will be treated as optional. 346 |

347 | 351 | updateSettings({ 352 | ...settings, 353 | valueOptional: e.currentTarget.value.trim(), 354 | }) 355 | } 356 | placeholder="Optional value" 357 | /> 358 |
359 | {codegen ? null : ( 360 |
361 |
362 |

Scale

363 |

Code scaling

364 | 377 |
378 |
379 | )} 380 |
381 | ) : null; 382 | } 383 | 384 | return mode === "RESULT" ? renderResults() : renderConfig(); 385 | } 386 | 387 | export default App; 388 | -------------------------------------------------------------------------------- /ui-src/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "prettier/esm/parser-babel.mjs"; 2 | declare module "prettier/esm/parser-vue.mjs"; 3 | declare module "prettier/esm/standalone.mjs"; 4 | -------------------------------------------------------------------------------- /ui-src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Component Inspector 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /ui-src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./App"; 4 | 5 | ReactDOM.render(, document.getElementById("root")); 6 | -------------------------------------------------------------------------------- /ui-src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": false, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /ui-src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import reactRefresh from "@vitejs/plugin-react-refresh"; 3 | import { viteSingleFile } from "vite-plugin-singlefile"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | root: "./ui-src", 8 | plugins: [reactRefresh(), viteSingleFile()], 9 | build: { 10 | target: "esnext", 11 | assetsInlineLimit: 100000000, 12 | chunkSizeWarningLimit: 100000000, 13 | cssCodeSplit: false, 14 | outDir: "../dist", 15 | rollupOptions: { 16 | output: {}, 17 | }, 18 | }, 19 | }); 20 | --------------------------------------------------------------------------------