diff --git a/cli/src/connect/intrinsics.ts b/cli/src/connect/intrinsics.ts
index d1080bf..b74b55c 100644
--- a/cli/src/connect/intrinsics.ts
+++ b/cli/src/connect/intrinsics.ts
@@ -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) {
@@ -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) {
@@ -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':
@@ -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') +
'}'
@@ -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 = ''
@@ -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`,
diff --git a/cli/src/react/__test__/SpecialCharProps.figma.tsx b/cli/src/react/__test__/SpecialCharProps.figma.tsx
new file mode 100644
index 0000000..3ad34e3
--- /dev/null
+++ b/cli/src/react/__test__/SpecialCharProps.figma.tsx
@@ -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 }) => (
+
+ ),
+})
diff --git a/cli/src/react/__test__/expected_templates/SpecialCharProps.expected_template b/cli/src/react/__test__/expected_templates/SpecialCharProps.expected_template
new file mode 100644
index 0000000..3a09ac7
--- /dev/null
+++ b/cli/src/react/__test__/expected_templates/SpecialCharProps.expected_template
@@ -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``, metadata: { __props } }
diff --git a/cli/src/react/__test__/parser.test.ts b/cli/src/react/__test__/parser.test.ts
index 25cb8fa..47b7b98 100644
--- a/cli/src/react/__test__/parser.test.ts
+++ b/cli/src/react/__test__/parser.test.ts
@@ -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')