diff --git a/src/formatter/formatComplexDataStructure.js b/src/formatter/formatComplexDataStructure.js index 57abb6136..6044b80c0 100644 --- a/src/formatter/formatComplexDataStructure.js +++ b/src/formatter/formatComplexDataStructure.js @@ -9,6 +9,19 @@ import formatFunction from './formatFunction'; import spacer from './spacer'; import type { Options } from './../options'; +const escapeStringForSingleQuotedLiteral = (s: string): string => + // Avoid regex literals with control characters to keep ESLint happy. + s + .replace(/\\/g, '\\\\') // Keep backslashes literal (avoid \1-style escapes) + .replace(/'/g, "\\'") + .replace(/\r/g, '\\r') + .replace(/\n/g, '\\n') + .replace(/\t/g, '\\t') + .split(String.fromCharCode(8)) + .join('\\b') + .split(String.fromCharCode(12)) + .join('\\f'); + export default ( value: Object | Array, inline: boolean, @@ -30,6 +43,13 @@ export default ( ); } + if (typeof currentValue === 'string') { + // pretty-print-object can output strings without escaping backslashes + // enough for JS string literal correctness, e.g. "\1" vs "\\1". + // We always output a valid single-quoted JS literal here. + return `'${escapeStringForSingleQuotedLiteral(currentValue)}'`; + } + if (typeof currentValue === 'function') { return formatFunction(currentValue, options); } diff --git a/src/formatter/formatProp.js b/src/formatter/formatProp.js index 9fcac3c01..1590d0cec 100644 --- a/src/formatter/formatProp.js +++ b/src/formatter/formatProp.js @@ -2,8 +2,12 @@ import spacer from './spacer'; import formatPropValue from './formatPropValue'; +import formatComplexDataStructure from './formatComplexDataStructure'; import type { Options } from './../options'; +const isValidJSXPropName = (propName: string): boolean => + /^[$A-Z_a-z][$\w-]*$/.test(propName); + export default ( name: string, hasValue: boolean, @@ -27,12 +31,14 @@ export default ( const usedValue = hasValue ? value : defaultValue; const { useBooleanShorthandSyntax, tabStop } = options; - - const formattedPropValue = formatPropValue(usedValue, inline, lvl, options); + const hasValidJSXPropName = isValidJSXPropName(name); + const formattedPropValue = hasValidJSXPropName + ? formatPropValue(usedValue, inline, lvl, options) + : null; let attributeFormattedInline = ' '; let attributeFormattedMultiline = `\n${spacer(lvl + 1, tabStop)}`; - const isMultilineAttribute = formattedPropValue.includes('\n'); + let attributePayload = ''; if ( useBooleanShorthandSyntax && @@ -42,14 +48,28 @@ export default ( // If a boolean is false and not different from it's default, we do not render the attribute attributeFormattedInline = ''; attributeFormattedMultiline = ''; + } else if (!hasValidJSXPropName) { + const formattedObjectSpreadValue = `{...${formatComplexDataStructure( + { [name]: usedValue }, + true, + lvl, + options + )}}`; + attributePayload = formattedObjectSpreadValue; + attributeFormattedInline += formattedObjectSpreadValue; + attributeFormattedMultiline += formattedObjectSpreadValue; } else if (useBooleanShorthandSyntax && formattedPropValue === '{true}') { + attributePayload = `${name}`; attributeFormattedInline += `${name}`; attributeFormattedMultiline += `${name}`; } else { - attributeFormattedInline += `${name}=${formattedPropValue}`; - attributeFormattedMultiline += `${name}=${formattedPropValue}`; + attributePayload = `${name}=${String(formattedPropValue)}`; + attributeFormattedInline += `${name}=${String(formattedPropValue)}`; + attributeFormattedMultiline += `${name}=${String(formattedPropValue)}`; } + const isMultilineAttribute = attributePayload.includes('\n'); + return { attributeFormattedInline, attributeFormattedMultiline, diff --git a/src/formatter/formatProp.spec.js b/src/formatter/formatProp.spec.js index 700c8fa9b..31f283b4d 100644 --- a/src/formatter/formatProp.spec.js +++ b/src/formatter/formatProp.spec.js @@ -2,8 +2,10 @@ import formatProp from './formatProp'; import formatPropValue from './formatPropValue'; +import formatComplexDataStructure from './formatComplexDataStructure'; jest.mock('./formatPropValue'); +jest.mock('./formatComplexDataStructure'); const defaultOptions = { useBooleanShorthandSyntax: true, @@ -219,4 +221,48 @@ describe('formatProp', () => { expect(formatPropValue).toHaveBeenCalledWith('bar', true, 4, options); }); + + it('should format non-jsx prop names with object spread syntax', () => { + formatComplexDataStructure.mockReturnValue( + `{'foo.bar': "MockedPropValue"}` + ); + + expect( + formatProp('foo.bar', true, 'bar', false, null, true, 0, defaultOptions) + ).toEqual({ + attributeFormattedInline: ` {...{'foo.bar': "MockedPropValue"}}`, + attributeFormattedMultiline: ` + {...{'foo.bar': "MockedPropValue"}}`, + isMultilineAttribute: false, + }); + + expect(formatComplexDataStructure).toHaveBeenCalledWith( + { 'foo.bar': 'bar' }, + true, + 0, + defaultOptions + ); + expect(formatPropValue).not.toHaveBeenCalled(); + }); + + it('should format non-jsx boolean prop names with object spread syntax', () => { + formatComplexDataStructure.mockReturnValue(`{'@flag': true}`); + + expect( + formatProp('@flag', true, true, false, null, true, 0, defaultOptions) + ).toEqual({ + attributeFormattedInline: ` {...{'@flag': true}}`, + attributeFormattedMultiline: ` + {...{'@flag': true}}`, + isMultilineAttribute: false, + }); + + expect(formatComplexDataStructure).toHaveBeenCalledWith( + { '@flag': true }, + true, + 0, + defaultOptions + ); + expect(formatPropValue).not.toHaveBeenCalled(); + }); }); diff --git a/src/formatter/formatReactElementNode.spec.js b/src/formatter/formatReactElementNode.spec.js index b4475457c..f1166f8e1 100644 --- a/src/formatter/formatReactElementNode.spec.js +++ b/src/formatter/formatReactElementNode.spec.js @@ -57,10 +57,10 @@ describe('formatReactElementNode', () => { type: 'ReactElement', displayName: 'div', defaultProps: { - a: { aa: '1', bb: { cc: '3' } }, + a: { aa: '\\1', bb: { cc: '3' } }, }, props: { - a: { aa: '1', bb: { cc: '3' } }, + a: { aa: '\\1', bb: { cc: '3' } }, }, childrens: [], }; @@ -68,7 +68,7 @@ describe('formatReactElementNode', () => { expect(formatReactElementNode(tree, false, 0, defaultOptions)).toEqual( `
{ return s; } - return `{\`${s}\`}`; + const escapedString = s.replace('${', '\\${').replace('`', '\\`'); + + return `{\`${escapedString}\`}`; }; const preserveTrailingSpace = (s: string) => { + const escapeNewlinesInSingleQuotedString = (str: string) => + // When we wrap whitespace into a JSX expression like `{'...'}` + // the content must be valid JS inside single quotes. + str.replace(/\r/g, '\\r').replace(/\n/g, '\\n'); + let result = s; if (result.endsWith(' ')) { - result = result.replace(/^(.*?)(\s+)$/, "$1{'$2'}"); + result = result.replace(/^(.*?)(\s+)$/, (m, p1, p2) => { + const escapedP2 = escapeNewlinesInSingleQuotedString(p2); + return `${p1}{'${escapedP2}'}`; + }); } if (result.startsWith(' ')) { - result = result.replace(/^(\s+)(.*)$/, "{'$1'}$2"); + result = result.replace(/^(\s+)(.*)$/, (m, p1, p2) => { + const escapedP1 = escapeNewlinesInSingleQuotedString(p1); + return `{'${escapedP1}'}${p2}`; + }); } return result; diff --git a/src/formatter/formatTreeNode.spec.js b/src/formatter/formatTreeNode.spec.js index a3d172e51..5c5d5777e 100644 --- a/src/formatter/formatTreeNode.spec.js +++ b/src/formatter/formatTreeNode.spec.js @@ -68,4 +68,16 @@ bar`); "foo": "bar" }\`}`); }); + + it('should escape newlines when preserving leading/trailing spaces', () => { + expect( + formatTreeNode({ type: 'string', value: ' \n ' }, true, 0, {}) + ).toBe(`{' \\n '}`); + }); + + it('should escape template string interpolation marker', () => { + expect(formatTreeNode({ type: 'string', value: '${' }, true, 0, {})).toBe( + ['{`\\', '{`}'].join('$') + ); + }); }); diff --git a/src/index.spec.js b/src/index.spec.js index c27217a49..81d0818a0 100644 --- a/src/index.spec.js +++ b/src/index.spec.js @@ -162,6 +162,18 @@ describe('reactElementToJSXString(ReactElement)', () => { ); }); + it("reactElementToJSXString(
)", () => { + expect( + reactElementToJSXString(
) + ).toEqual(`
`); + }); + + it("reactElementToJSXString(
)", () => { + expect(reactElementToJSXString(
)).toEqual( + `
` + ); + }); + it('reactElementToJSXString(
)', () => { expect(reactElementToJSXString(
)).toEqual( '
' @@ -1360,4 +1372,12 @@ describe('reactElementToJSXString(ReactElement)', () => { }} />`); }); + + it('should escape ${ for string interpolation', () => { + expect(reactElementToJSXString(
${'{'}
)).toEqual( + `
+ {\`\\\${\`} +
` + ); + }); });