Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 30 additions & 18 deletions cli/src/connect/intrinsics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,11 +465,21 @@ export function parseIntrinsic(exp: ts.CallExpression, parserContext: ParserCont
}

/**
* Replace newlines in enum values with \\n so that we don't output
* broken JS with newlines inside the string
* Escape characters that would break single-quoted JS string literals.
* Used for property names, layer names, and enum values interpolated
* into generated template code.
*/
function replaceNewlines(str: string) {
return str.toString().replaceAll('\n', '\\n').replaceAll("'", "\\'")
function escapeForSingleQuotedString(str: string) {
return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n')
}

/**
* Escape characters that would break double-quoted JS string literals.
* Used for mapping keys and layer names interpolated into generated
* template code within double quotes.
*/
function escapeForDoubleQuotedString(str: string) {
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')
}

export function valueToString(value: ValueMappingKind, childLayer?: string) {
Expand All @@ -478,7 +488,7 @@ export function valueToString(value: ValueMappingKind, childLayer?: string) {
}

if (typeof value === 'string') {
return `'${replaceNewlines(value)}'`
return `'${escapeForSingleQuotedString(value)}'`
}

if ('kind' in value) {
Expand All @@ -488,7 +498,7 @@ export function valueToString(value: ValueMappingKind, childLayer?: string) {

// Convert objects to strings
const str = typeof value.$value === 'string' ? value.$value : `${JSON.stringify(value.$value)}`
const v = replaceNewlines(str)
const v = escapeForSingleQuotedString(str)

switch (value.$type) {
case 'function':
Expand Down Expand Up @@ -517,7 +527,7 @@ export function valueMappingToString(valueMapping: ValueMapping, childLayer?: st
'{\n' +
Object.entries(valueMapping)
.map(([key, value]) => {
return `"${key}": ${valueToString(value, childLayer)}`
return `"${escapeForDoubleQuotedString(key)}": ${valueToString(value, childLayer)}`
})
.join(',\n') +
'}'
Expand All @@ -533,47 +543,49 @@ export function intrinsicToString(
const selector = childLayer ?? `figma.currentLayer`
switch (kind) {
case IntrinsicKind.String:
return `${selector}.__properties__.string('${args.figmaPropName}')`
return `${selector}.__properties__.string('${escapeForSingleQuotedString(args.figmaPropName)}')`
case IntrinsicKind.Instance: {
// Outputs:
// `const propName = figma.properties.string('propName')`, or
// `const propName = figma.properties.boolean('propName')`, or
// `const propName = figma.properties.instance('propName')`
const escapedName = escapeForSingleQuotedString(args.figmaPropName)
if (modifiers.length > 0) {
const instance = `${selector}.__properties__.__instance__('${args.figmaPropName}')`
const instance = `${selector}.__properties__.__instance__('${escapedName}')`
let body = `const instance = ${instance}\n`
body += `return instance && instance.type !== "ERROR" ? ${['instance', ...modifiers.map(modifierToString)].join('.')} : instance`
return `(function () {${body}})()`
}
return `${selector}.__properties__.instance('${args.figmaPropName}')`
return `${selector}.__properties__.instance('${escapedName}')`
}
case IntrinsicKind.Boolean: {
const escapedName = escapeForSingleQuotedString(args.figmaPropName)
if (args.valueMapping) {
const mappingString = valueMappingToString(args.valueMapping, childLayer)
// Outputs: `const propName = figma.properties.boolean('propName', { ... mapping object from above ... })`
return `${selector}.__properties__.boolean('${args.figmaPropName}', ${mappingString})`
return `${selector}.__properties__.boolean('${escapedName}', ${mappingString})`
}
return `${selector}.__properties__.boolean('${args.figmaPropName}')`
return `${selector}.__properties__.boolean('${escapedName}')`
}
case IntrinsicKind.Enum: {
const mappingString = valueMappingToString(args.valueMapping, childLayer)

// Outputs: `const propName = figma.properties.enum('propName', { ... mapping object from above ... })`
return `${selector}.__properties__.enum('${args.figmaPropName}', ${mappingString})`
return `${selector}.__properties__.enum('${escapeForSingleQuotedString(args.figmaPropName)}', ${mappingString})`
}
case IntrinsicKind.Slot: {
return `${selector}.__properties__.slot('${args.figmaPropName}')`
return `${selector}.__properties__.slot('${escapeForSingleQuotedString(args.figmaPropName)}')`
}
case IntrinsicKind.Children: {
// Outputs: `const propName = figma.properties.children(["Layer 1", "Layer 2"])`
return `${selector}.__properties__.children([${args.layers.map((layerName) => `"${layerName}"`).join(',')}])`
return `${selector}.__properties__.children([${args.layers.map((layerName) => `"${escapeForDoubleQuotedString(layerName)}"`).join(',')}])`
}
case IntrinsicKind.ClassName: {
// Outputs: `const propName = ['btn-base', figma.currentLayer.__properties__.enum('Size, { Large: 'btn-large' })].join(" ")`
return `[${args.className.map((className) => (typeof className === 'string' ? `"${className}"` : `${intrinsicToString(className, childLayer)}`)).join(', ')}].filter(v => !!v).join(' ')`
return `[${args.className.map((className) => (typeof className === 'string' ? `"${escapeForDoubleQuotedString(className)}"` : `${intrinsicToString(className, childLayer)}`)).join(', ')}].filter(v => !!v).join(' ')`
}
case IntrinsicKind.TextContent: {
return `${selector}.__findChildWithCriteria__({ name: '${args.layer}', type: "TEXT" }).__render__()`
return `${selector}.__findChildWithCriteria__({ name: '${escapeForSingleQuotedString(args.layer)}', type: "TEXT" }).__render__()`
}
case IntrinsicKind.NestedProps: {
let body: string = ''
Expand All @@ -583,7 +595,7 @@ export function intrinsicToString(
// for each nested layer reference instead. The only reason it's wrapped in a funciton
// currently is to keep the error checking out of global scope
const nestedLayerRef = `nestedLayer${nestedLayerCount++}`
body += `const ${nestedLayerRef} = figma.currentLayer.__find__("${args.layer}")\n`
body += `const ${nestedLayerRef} = figma.currentLayer.__find__("${escapeForDoubleQuotedString(args.layer)}")\n`
body += `return ${nestedLayerRef}.type === "ERROR" ? ${nestedLayerRef} : {
${Object.entries(args.props).map(
([key, intrinsic]) => `${key}: ${intrinsicToString(intrinsic, nestedLayerRef)}\n`,
Expand Down
24 changes: 24 additions & 0 deletions cli/src/react/__test__/SpecialCharProps.figma.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from 'react'
import figma from '../index_react'
import { Button } from './components/TestComponents'

// Test that property names containing apostrophes and other special characters
// are correctly escaped in the generated template code.
// Without escaping, a name like "Tab's number" would produce:
// string('Tab's number') — broken JS (apostrophe closes the string literal)
figma.connect(Button, 'specialCharProps', {
props: {
label: figma.string("Tab's number"),
variant: figma.enum("it's variant", {
'Primary': 'primary',
"It's secondary": 'secondary',
'He said "hello"': 'quoted',
}),
disabled: figma.boolean("it's disabled"),
},
example: ({ label, variant, disabled }) => (
<Button variant={variant} disabled={disabled}>
{label}
</Button>
),
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const figma = require('figma')

const label = figma.currentLayer.__properties__.string('Tab\'s number')
const variant = figma.currentLayer.__properties__.enum('it\'s variant', {
"Primary": 'primary',
"It's secondary": 'secondary',
"He said \"hello\"": 'quoted'})
const disabled = figma.currentLayer.__properties__.boolean('it\'s disabled')
const __props = {}
if (label && label.type !== 'ERROR') {
__props["label"] = label
}
if (variant && variant.type !== 'ERROR') {
__props["variant"] = variant
}
if (disabled && disabled.type !== 'ERROR') {
__props["disabled"] = disabled
}

export default { ...figma.tsx`<Button${_fcc_renderReactProp('variant', variant)}${_fcc_renderReactProp('disabled', disabled)}>
${_fcc_renderReactChildren(label)}
</Button>`, metadata: { __props } }
39 changes: 39 additions & 0 deletions cli/src/react/__test__/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,45 @@ describe('Parser (JS templates)', () => {
])
})

it('Escapes special characters in Figma property names so generated template code is valid JS', async () => {
const result = await testParse('SpecialCharProps.figma.tsx')

expect(result).toMatchObject([
{
figmaNode: 'specialCharProps',
label: 'React',
language: 'typescript',
component: 'Button',
source: expect.stringMatching(
getFileInRepositoryRegex('cli/src/react/__test__/components/TestComponents.tsx'),
),
sourceLocation: { line: 12 },
template: getExpectedTemplate('SpecialCharProps'),
templateData: {
props: {
label: { kind: 'string', args: { figmaPropName: "Tab's number" } },
variant: {
kind: 'enum',
args: {
figmaPropName: "it's variant",
valueMapping: {
Primary: 'primary',
"It's secondary": 'secondary',
'He said "hello"': 'quoted',
},
},
},
disabled: {
kind: 'boolean',
args: { figmaPropName: "it's disabled" },
},
},
imports: ["import { Button } from './components/TestComponents'"],
},
},
])
})

it('handles enum-like boolean props with values for false', async () => {
const result = await testParse('EnumLikeBooleanFalseProp.figma.tsx')

Expand Down