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` + ${_fcc_renderReactChildren(label)} + `, 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')