From baa092c3d521d8b1dcbaeba1e78bbc2d24c30468 Mon Sep 17 00:00:00 2001 From: Dimitri BARBOT Date: Sat, 21 Jan 2023 00:05:00 +0100 Subject: [PATCH] Add missing props to extracted components when expressions are used --- CHANGELOG.md | 19 ++++++ README.md | 2 +- package.json | 2 +- src/extension.ts | 32 +++++----- src/lib/extractor.test.ts | 67 ++++++++++++++++----- src/lib/extractor.ts | 101 ++++++++++++++++++++++++++------ src/lib/modify-vscode-editor.ts | 41 +++++++------ 7 files changed, 194 insertions(+), 70 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..49b10f2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +- Nothing yet! + +## [0.3.2] - 2023-01-21 + +### Fixed + +- Add missing props to extracted components when expressions are used + +[unreleased]: https://github.com/dimitribarbot/tailwind-styled-components-extractor/compare/v0.3.2...HEAD +[0.3.2]: https://github.com/dimitribarbot/tailwind-styled-components-extractor/compare/b72f621adfcd460d7f15241dea247ebaa074dbea...v0.3.2 diff --git a/README.md b/README.md index f60e6ce..f9c7abd 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Styled-Components Extractor +# Tailwind Styled-Components Extractor Generate [tailwind-styled-components](https://www.npmjs.com/package/tailwind-styled-components) definitions from JSX tags. This extension is based on the [existing one for styled-components](https://marketplace.visualstudio.com/items?itemName=FallenMax.styled-components-extractor). diff --git a/package.json b/package.json index ba8e4a8..3aadecc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tailwind-styled-components-extractor", "displayName": "Tailwind Styled-Components Extractor", - "version": "0.3.1", + "version": "0.3.2", "description": "Generate tailwind styled-components from JSX tags. A faster tailwind styled-component workflow.", "license": "MIT", "publisher": "dimitribarbot", diff --git a/src/extension.ts b/src/extension.ts index 4b97d10..6243a7e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,7 +4,7 @@ import { getCorrespondingStyleFile } from "./lib/corresponding-file"; import { collectUnboundComponents, Component, - extractUnboundComponentClassNameOffsets, + getComponentsSortedByClassNameOffsets, extractUnboundComponentNames, generateDeclarations, getUnderlyingComponent, @@ -17,7 +17,7 @@ import { insertTailwindStyledImport, insertStyles, modifyImports, - removeClassNames, + replaceComponentClassNamesWithPropAttributes, executeFormatCommand, renameTag } from "./lib/modify-vscode-editor"; @@ -85,10 +85,7 @@ const extractCurrentToSeparateFile = async ( component.name, component.closingTagOffsets ? [component.closingTagOffsets] : [] ); - await removeClassNames( - editor, - component.classNameOffsets ? [component.classNameOffsets] : [] - ); + await replaceComponentClassNamesWithPropAttributes(editor, [component]); await renameTag(editor, component.name, [component.openingTagOffsets]); await modifyImports(editor, styleFile, [component.name]); await executeFormatCommand(); @@ -112,10 +109,7 @@ const extractCurrentToSameFile = async ( component.name, component.closingTagOffsets ? [component.closingTagOffsets] : [] ); - await removeClassNames( - editor, - component.classNameOffsets ? [component.classNameOffsets] : [] - ); + await replaceComponentClassNamesWithPropAttributes(editor, [component]); await renameTag(editor, component.name, [component.openingTagOffsets]); await insertStyles(editor, declarations); @@ -171,9 +165,12 @@ const extractUnboundToSeparateFile = async ( } const unboundComponentNames = extractUnboundComponentNames(components); - const unboundComponentClassNameOffsets = - extractUnboundComponentClassNameOffsets(components); - await removeClassNames(editor, unboundComponentClassNameOffsets); + const componentsSortedByClassNameOffsets = + getComponentsSortedByClassNameOffsets(components); + await replaceComponentClassNamesWithPropAttributes( + editor, + componentsSortedByClassNameOffsets + ); await modifyImports(editor, styleFile, unboundComponentNames); await executeFormatCommand(); @@ -191,9 +188,12 @@ const extractUnboundToSameFile = async ( components: UnboundComponent[], declarations: string ) => { - const unboundComponentClassNameOffsets = - extractUnboundComponentClassNameOffsets(components); - await removeClassNames(editor, unboundComponentClassNameOffsets); + const componentsSortedByClassNameOffsets = + getComponentsSortedByClassNameOffsets(components); + await replaceComponentClassNamesWithPropAttributes( + editor, + componentsSortedByClassNameOffsets + ); await insertStyles(editor, declarations); await insertTailwindStyledImport(editor); diff --git a/src/lib/extractor.test.ts b/src/lib/extractor.test.ts index 091137f..44f8116 100644 --- a/src/lib/extractor.test.ts +++ b/src/lib/extractor.test.ts @@ -1,7 +1,7 @@ import { collectUnboundComponents, Component, - extractUnboundComponentClassNameOffsets, + getComponentsSortedByClassNameOffsets, extractUnboundComponentNames, generateDeclarations, getUnderlyingComponent, @@ -70,7 +70,7 @@ describe("collectUnboundComponents", () => { - +
@@ -85,33 +85,40 @@ describe("collectUnboundComponents", () => { } `; - expect(collectUnboundComponents(code)).toEqual([ + const expectedUnboundComponents: UnboundComponent[] = [ { name: "Abc", + propNames: [], className: "flex flex-col", classNameOffsets: { start: 141, end: 166 } }, { name: "Efg", + propNames: ["c"], className: '${({ c }) => c ? "justify-center" : "justify-start"}', classNameOffsets: { start: 197, end: 247 } }, { name: "Ghi", + propNames: ["c", "a"], className: - '${({ c, a }) => c(a?.e) && "flex"} ${({ a, b, c }) => (a && b) || c ? "justify-center" : "justify-start"} flex flex-col', + '${({ c, a }) => c(a?.e) && "flex"} ${({ c, a, b }) => (a && b) || c ? "justify-center" : "justify-start"} flex flex-col', classNameOffsets: { start: 266, end: 368 } }, { name: "Efg", + propNames: [], className: "justify-center", - classNameOffsets: { start: 387, end: 413 } + classNameOffsets: { start: 393, end: 419 } }, { name: "Ghi", + propNames: [], className: "" } - ]); + ]; + + expect(collectUnboundComponents(code)).toEqual(expectedUnboundComponents); }); it("should return collected unbound components in case of syntax error", async () => { @@ -152,11 +159,13 @@ describe("generateDeclarations", () => { const unboundComponents: UnboundComponent[] = [ { name: "Abc", + propNames: [], className: "flex flex-col", classNameOffsets: { start: 124, end: 149 } }, { name: "Xyz", + propNames: ["c"], className: '${({ c }) => c ? "justify-center" : "justify-start"}', classNameOffsets: { start: 176, end: 226 } } @@ -176,11 +185,13 @@ describe("generateDeclarations", () => { const unboundComponents: UnboundComponent[] = [ { name: "Abc", + propNames: [], className: "flex flex-col", classNameOffsets: { start: 124, end: 149 } }, { name: "Xyz", + propNames: ["c"], className: '${({ c }) => c ? "justify-center" : "justify-start"}', classNameOffsets: { start: 176, end: 226 } } @@ -200,6 +211,7 @@ describe("generateDeclarations", () => { const components: Component[] = [ { name: "Abc", + propNames: [], type: "span", className: "flex flex-col", classNameOffsets: { @@ -218,6 +230,7 @@ describe("generateDeclarations", () => { }, { name: "Xyz", + propNames: ["c"], type: "Efg", className: '${({ c }) => c ? "justify-center" : "justify-start"}', classNameOffsets: { @@ -247,6 +260,7 @@ describe("generateDeclarations", () => { const components: Component[] = [ { name: "Abc", + propNames: [], type: "span", className: "flex flex-col", classNameOffsets: { @@ -265,6 +279,7 @@ describe("generateDeclarations", () => { }, { name: "Xyz", + propNames: ["c"], type: "Efg", className: '${({ c }) => c ? "justify-center" : "justify-start"}', classNameOffsets: { @@ -296,11 +311,13 @@ describe("extractUnboundComponentNames", () => { const unboundComponentNames = extractUnboundComponentNames([ { name: "Abc", + propNames: [], className: "flex flex-col", classNameOffsets: { start: 124, end: 149 } }, { name: "Xyz", + propNames: ["c"], className: '${({ c }) => c ? "justify-center" : "justify-start"}', classNameOffsets: { start: 176, end: 226 } } @@ -309,24 +326,36 @@ describe("extractUnboundComponentNames", () => { }); }); -describe("extractUnboundComponentClassNameOffsets", () => { +describe("getComponentsSortedByClassNameOffsets", () => { it("should return class name offsets", async () => { - const unboundComponentClassNameOffsets = - extractUnboundComponentClassNameOffsets([ + const componentsSortedByClassNameOffsets = + getComponentsSortedByClassNameOffsets([ { name: "Abc", + propNames: [], className: "flex flex-col", classNameOffsets: { start: 124, end: 149 } }, { name: "Xyz", + propNames: ["c"], className: '${({ c }) => c ? "justify-center" : "justify-start"}', classNameOffsets: { start: 176, end: 226 } } ]); - expect(unboundComponentClassNameOffsets).toEqual([ - { start: 176, end: 226 }, - { start: 124, end: 149 } + expect(componentsSortedByClassNameOffsets).toEqual([ + { + name: "Xyz", + propNames: ["c"], + className: '${({ c }) => c ? "justify-center" : "justify-start"}', + classNameOffsets: { start: 176, end: 226 } + }, + { + name: "Abc", + propNames: [], + className: "flex flex-col", + classNameOffsets: { start: 124, end: 149 } + } ]); }); }); @@ -358,8 +387,9 @@ const TestComponent: React.FC = ({ a }) => { `; it("should return underlying component without self closing tag", () => { - expect(getUnderlyingComponent(code, 140)).toEqual({ + const expectedComponent: Component = { name: "", + propNames: [], type: "Abc", className: "flex flex-col", classNameOffsets: { @@ -375,12 +405,15 @@ const TestComponent: React.FC = ({ a }) => { start: 527, end: 530 } - }); + }; + + expect(getUnderlyingComponent(code, 140)).toEqual(expectedComponent); }); it("should return underlying component with self closing tag", () => { - expect(getUnderlyingComponent(code, 380)).toEqual({ + const expectedComponent: Component = { name: "", + propNames: [], type: "Efg", className: "justify-center", classNameOffsets: { @@ -393,7 +426,9 @@ const TestComponent: React.FC = ({ a }) => { }, selfClosing: true, closingTagOffsets: undefined - }); + }; + + expect(getUnderlyingComponent(code, 380)).toEqual(expectedComponent); }); }); diff --git a/src/lib/extractor.ts b/src/lib/extractor.ts index 43e146f..a8144d7 100644 --- a/src/lib/extractor.ts +++ b/src/lib/extractor.ts @@ -18,6 +18,7 @@ import { isTemplateLiteral, JSXAttribute, JSXElement, + JSXIdentifier, JSXOpeningElement, Node } from "@babel/types"; @@ -165,6 +166,7 @@ export interface Offsets { export interface UnboundComponent { name: string; + propNames: string[]; className?: string; classNameOffsets?: Offsets; } @@ -188,8 +190,10 @@ const fillExpressionIdentifiers = ( expression: Expression, identifiers: string[] ) => { - if (isIdentifier(expression) && !identifiers.includes(expression.name)) { - identifiers.push(expression.name); + if (isIdentifier(expression)) { + if (!identifiers.includes(expression.name)) { + identifiers.push(expression.name); + } } else if ( isOptionalMemberExpression(expression) || isMemberExpression(expression) @@ -210,14 +214,23 @@ const fillExpressionIdentifiers = ( } else if (isLogicalExpression(expression)) { fillExpressionIdentifiers(expression.left, identifiers); fillExpressionIdentifiers(expression.right, identifiers); + } else if (isTemplateLiteral(expression)) { + expression.expressions + .filter(expr => isExpression(expr)) + .forEach(expr => + fillExpressionIdentifiers(expr as Expression, identifiers) + ); } else if (!isStringLiteral(expression)) { console.log("Unknown expression:", expression); } }; -const buildExpressionText = (code: string, expression: Expression) => { +const buildExpressionText = ( + code: string, + expression: Expression, + identifiers: string[] +) => { if (isDefined(expression.start) && isDefined(expression.end)) { - const identifiers: string[] = []; fillExpressionIdentifiers(expression, identifiers); return `\${({ ${identifiers.join(", ")} }) => ${code.slice( expression.start, @@ -233,9 +246,24 @@ const extractClassNameAttribute = (jsxOpeningNode: JSXOpeningElement) => isJSXAttribute(attribute) && attribute.name.name === "className" ) as JSXAttribute | undefined; +const filterExistingAttributesFromClassNameIdentifiers = ( + jsxOpeningNode: JSXOpeningElement, + classNameIdentifiers: string[] +) => { + const attributeNames = jsxOpeningNode.attributes + .filter( + attribute => isJSXAttribute(attribute) && isJSXIdentifier(attribute.name) + ) + .map(attribute => ((attribute as JSXAttribute).name as JSXIdentifier).name); + return classNameIdentifiers.filter( + classNameIdentifier => !attributeNames.includes(classNameIdentifier) + ); +}; + const extractClassName = ( code: string, - classNameAttribute: JSXAttribute | null | undefined + classNameAttribute: JSXAttribute | null | undefined, + identifiers: string[] ) => { if (!classNameAttribute?.value) return ""; if (classNameAttribute.value.type === "StringLiteral") { @@ -250,7 +278,7 @@ const extractClassName = ( if (isTemplateLiteral(expression)) { const expressionText = expression.expressions .filter(expr => isExpression(expr)) - .map(expr => buildExpressionText(code, expr as Expression)) + .map(expr => buildExpressionText(code, expr as Expression, identifiers)) .join(" "); const quasisText = expression.quasis .map(quasi => quasi.value.raw?.trim() || "") @@ -259,7 +287,7 @@ const extractClassName = ( return `${expressionText} ${quasisText}`; } - return buildExpressionText(code, expression); + return buildExpressionText(code, expression, identifiers); } return ""; }; @@ -274,9 +302,28 @@ const getNodeOffsets = (node: Node | null | undefined) => { return undefined; }; -const sortOffsets = (offsetsA: Offsets, offsetsB: Offsets) => { - if (offsetsA.start < offsetsB.start) return 1; - if (offsetsA.start > offsetsB.start) return -1; +const sortComponentByOffsets = ( + componentA: Component | UnboundComponent, + componentB: Component | UnboundComponent +) => { + if (!componentA.classNameOffsets) { + if (componentB.classNameOffsets) { + return -1; + } + return 0; + } + if (!componentB.classNameOffsets) { + if (componentA.classNameOffsets) { + return 1; + } + return 0; + } + if (componentA.classNameOffsets.start < componentB.classNameOffsets.start) { + return 1; + } + if (componentA.classNameOffsets.start > componentB.classNameOffsets.start) { + return -1; + } return 0; }; @@ -351,10 +398,20 @@ export const collectUnboundComponents = (code: string) => { if (!path.scope.hasBinding(node.name)) { const jsxOpeningNode = path.parentPath.node; const classNameAttribute = extractClassNameAttribute(jsxOpeningNode); - const className = extractClassName(code, classNameAttribute); + const classNameIdentifiers: string[] = []; + const className = extractClassName( + code, + classNameAttribute, + classNameIdentifiers + ); + const propNames = filterExistingAttributesFromClassNameIdentifiers( + jsxOpeningNode, + classNameIdentifiers + ); const classNameOffsets = getNodeOffsets(classNameAttribute); unboundJSXIdentifiers.add({ name: node.name, + propNames, className, classNameOffsets }); @@ -370,13 +427,9 @@ export const extractUnboundComponentNames = ( unboundComponents: UnboundComponent[] ) => unboundComponents?.map(component => component.name); -export const extractUnboundComponentClassNameOffsets = ( - unboundComponents: Component[] | UnboundComponent[] -) => - unboundComponents - ?.filter(component => !!component.classNameOffsets) - .map(component => component.classNameOffsets as Offsets) - .sort(sortOffsets) || []; +export const getComponentsSortedByClassNameOffsets = ( + components: Component[] | UnboundComponent[] +) => [...components].sort(sortComponentByOffsets); export const generateDeclarations = ({ components, @@ -412,12 +465,22 @@ export const getUnderlyingComponent = ( } const classNameAttribute = extractClassNameAttribute(node.openingElement); - const className = extractClassName(code, classNameAttribute); + const classNameIdentifiers: string[] = []; + const className = extractClassName( + code, + classNameAttribute, + classNameIdentifiers + ); + const propNames = filterExistingAttributesFromClassNameIdentifiers( + node.openingElement, + classNameIdentifiers + ); const classNameOffsets = getNodeOffsets(classNameAttribute); const closingTagOffsets = getNodeOffsets(node.closingElement?.name); return { name: "", + propNames, type: node.openingElement.name.name, selfClosing: !!node.openingElement.selfClosing, openingTagOffsets, diff --git a/src/lib/modify-vscode-editor.ts b/src/lib/modify-vscode-editor.ts index b9cc213..eab4cc2 100644 --- a/src/lib/modify-vscode-editor.ts +++ b/src/lib/modify-vscode-editor.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; -import { Offsets } from "./extractor"; +import { Component, Offsets, UnboundComponent } from "./extractor"; import { getImportInsertion, getTailwindStyledImportInsertion @@ -8,15 +8,19 @@ import { import { relativeImportPathFromFile } from "./path-utils"; import { endOfFile } from "./vscode-utils"; +const convertOffsetsToRange = (editor: vscode.TextEditor, offsets: Offsets) => { + const startPosition = editor.document.positionAt(offsets.start); + const endPosition = editor.document.positionAt(offsets.end); + return new vscode.Range(startPosition, endPosition); +}; + const convertOffsetsToRanges = ( editor: vscode.TextEditor, offsetsToConvert: Offsets[] -) => - offsetsToConvert.map(offsets => { - const startPosition = editor.document.positionAt(offsets.start); - const endPosition = editor.document.positionAt(offsets.end); - return new vscode.Range(startPosition, endPosition); - }); +) => offsetsToConvert.map(offsets => convertOffsetsToRange(editor, offsets)); + +const generatePropAttributes = (propNames: string[]) => + propNames.map((propName: string) => `${propName}={${propName}}`).join(" "); export const executeFormatCommand = () => vscode.commands.executeCommand("editor.action.formatDocument"); @@ -57,19 +61,22 @@ export const renameTag = async ( }); }; -export const removeClassNames = async ( +export const replaceComponentClassNamesWithPropAttributes = async ( editor: vscode.TextEditor, - classNameOffsetsToRemove: Offsets[] + components: Component[] | UnboundComponent[] ) => { - const classNameRangesToRemove = convertOffsetsToRanges( - editor, - classNameOffsetsToRemove - ); - await editor.edit(editBuilder => { - classNameRangesToRemove.forEach(classNameRangeToRemove => { - editBuilder.delete(classNameRangeToRemove); - }); + for (const component of components) { + if (component.classNameOffsets) { + const propAttributes = generatePropAttributes(component.propNames); + const classNameRangeToRemove = convertOffsetsToRange( + editor, + component.classNameOffsets + ); + + editBuilder.replace(classNameRangeToRemove, propAttributes); + } + } }); };