snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
This commit is contained in:
10
packages/codemods/.prettierignore
Normal file
10
packages/codemods/.prettierignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.next/
|
||||
.react-router/
|
||||
.turbo/
|
||||
.vite/
|
||||
build/
|
||||
dist/
|
||||
node_modules/
|
||||
out/
|
||||
pnpm-lock.yaml
|
||||
storybook-static/
|
||||
537
packages/codemods/function-declaration.ts
Normal file
537
packages/codemods/function-declaration.ts
Normal file
@@ -0,0 +1,537 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { CommentKind } from "ast-types/gen/kinds";
|
||||
import {
|
||||
API,
|
||||
FileInfo,
|
||||
Options,
|
||||
JSCodeshift,
|
||||
TSTypeReference,
|
||||
Identifier,
|
||||
BlockStatement,
|
||||
Expression,
|
||||
Pattern,
|
||||
SpreadElement,
|
||||
JSXNamespacedName,
|
||||
ASTNode,
|
||||
Node,
|
||||
FunctionDeclaration,
|
||||
TSType,
|
||||
VariableDeclarator,
|
||||
ArrowFunctionExpression,
|
||||
FunctionExpression,
|
||||
} from "jscodeshift";
|
||||
|
||||
const COMPONENT_TYPE_NAMES = new Set([
|
||||
"FC",
|
||||
"FunctionComponent",
|
||||
"VFC",
|
||||
"VoidFunctionComponent",
|
||||
]);
|
||||
|
||||
const COMPONENT_NAME_PATTERN = /^[A-Z]/;
|
||||
|
||||
function isReactComponentType(typeReference: TSTypeReference, j: JSCodeshift) {
|
||||
const typeName = typeReference.typeName;
|
||||
|
||||
if (!typeName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (j.Identifier.check(typeName)) {
|
||||
return COMPONENT_TYPE_NAMES.has(typeName.name);
|
||||
}
|
||||
|
||||
if (
|
||||
j.TSQualifiedName.check(typeName) &&
|
||||
j.Identifier.check(typeName.left) &&
|
||||
j.Identifier.check(typeName.right)
|
||||
) {
|
||||
return (
|
||||
typeName.left.name === "React" &&
|
||||
COMPONENT_TYPE_NAMES.has(typeName.right.name)
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isComponentNameIdentifier(identifier: Identifier | null | undefined) {
|
||||
if (!identifier) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must start with uppercase
|
||||
if (!COMPONENT_NAME_PATTERN.test(identifier.name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ignore CONSTANT_CASE (all uppercase)
|
||||
if (
|
||||
identifier.name.toUpperCase() === identifier.name &&
|
||||
identifier.name.length > 1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function addComments(target: Node, comments: CommentKind[]) {
|
||||
if (!comments || comments.length === 0) {
|
||||
return;
|
||||
}
|
||||
target.comments ||= [];
|
||||
target.comments.push(...comments);
|
||||
}
|
||||
|
||||
function copyOuterComments(source: Node, target: Node, j: JSCodeshift) {
|
||||
if (!j.Node.check(source) || !j.Node.check(target) || !source.comments) {
|
||||
return;
|
||||
}
|
||||
const outerComments = source.comments.filter((c) => c.leading || c.trailing);
|
||||
addComments(target, outerComments);
|
||||
}
|
||||
|
||||
function ensureParamType(
|
||||
param: Pattern,
|
||||
propsType: TSType | null | undefined,
|
||||
j: JSCodeshift
|
||||
) {
|
||||
if (!j.Pattern.check(param)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!("typeAnnotation" in param)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!propsType) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (j.TSTypeReference.check(propsType) && propsType.typeName) {
|
||||
param.typeAnnotation = j.tsTypeAnnotation(
|
||||
propsType.typeParameters
|
||||
? j.tsTypeReference(propsType.typeName, propsType.typeParameters)
|
||||
: j.tsTypeReference(propsType.typeName)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (j.TSType.check(propsType)) {
|
||||
// @ts-expect-error: jscodeshift types are too strict here
|
||||
param.typeAnnotation = j.tsTypeAnnotation(propsType);
|
||||
}
|
||||
}
|
||||
|
||||
function toBlockBody(j: JSCodeshift, body: BlockStatement | Expression) {
|
||||
if (j.BlockStatement.check(body)) {
|
||||
return body;
|
||||
}
|
||||
|
||||
// @ts-expect-error: jscodeshift types are too strict here
|
||||
const returnStatement = j.returnStatement(body);
|
||||
|
||||
return j.blockStatement([returnStatement]);
|
||||
}
|
||||
|
||||
function isFunction(
|
||||
node: Node,
|
||||
j: JSCodeshift
|
||||
): node is ArrowFunctionExpression | FunctionExpression {
|
||||
return (
|
||||
j.ArrowFunctionExpression.check(node) || j.FunctionExpression.check(node)
|
||||
);
|
||||
}
|
||||
|
||||
function extractArrowFunction(
|
||||
init: Expression | SpreadElement | JSXNamespacedName,
|
||||
j: JSCodeshift
|
||||
): ArrowFunctionExpression | FunctionExpression | undefined {
|
||||
if (isFunction(init, j)) {
|
||||
return init;
|
||||
}
|
||||
|
||||
// If it's a CallExpression like observer(() => {}), extract the arrow function
|
||||
if (j.CallExpression.check(init)) {
|
||||
const firstArg = init.arguments?.[0];
|
||||
if (firstArg && isFunction(firstArg, j)) {
|
||||
return firstArg;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
function extractPropsTypeFromWrapper(
|
||||
init: Expression | SpreadElement | JSXNamespacedName,
|
||||
j: JSCodeshift
|
||||
) {
|
||||
// If it's a CallExpression like observer<React.FC<Props>>((props) => {})
|
||||
// Extract the Props type from React.FC<Props>
|
||||
if (!j.CallExpression.check(init)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!("typeParameters" in init)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const typeParameters = init.typeParameters;
|
||||
if (!j.TSTypeParameterInstantiation.check(typeParameters)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const typeParam = typeParameters.params?.[0];
|
||||
|
||||
if (!typeParam) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
j.TSTypeReference.check(typeParam) &&
|
||||
isReactComponentType(typeParam, j)
|
||||
) {
|
||||
// Extract the generic type from React.FC<PropsType>
|
||||
return typeParam.typeParameters?.params?.[0];
|
||||
}
|
||||
|
||||
// Check for memo<Props>
|
||||
const callee = init.callee;
|
||||
let isMemo = false;
|
||||
if (j.Identifier.check(callee) && callee.name === "memo") {
|
||||
isMemo = true;
|
||||
} else if (
|
||||
j.MemberExpression.check(callee) &&
|
||||
j.Identifier.check(callee.property) &&
|
||||
callee.property.name === "memo"
|
||||
) {
|
||||
isMemo = true;
|
||||
}
|
||||
|
||||
if (isMemo) {
|
||||
// For memo<Props>, the first type parameter is the props type
|
||||
// @ts-expect-error: jscodeshift types are too strict here
|
||||
return typeParam;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
function isReactForwardRef(
|
||||
init: Expression | SpreadElement | JSXNamespacedName,
|
||||
j: JSCodeshift
|
||||
) {
|
||||
if (!j.CallExpression.check(init)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const callee = init.callee;
|
||||
|
||||
// Check for React.forwardRef
|
||||
if (
|
||||
j.MemberExpression.check(callee) &&
|
||||
j.Identifier.check(callee.object) &&
|
||||
j.Identifier.check(callee.property)
|
||||
) {
|
||||
return (
|
||||
callee.object.name === "React" && callee.property.name === "forwardRef"
|
||||
);
|
||||
}
|
||||
|
||||
// Check for forwardRef (imported directly)
|
||||
if (j.Identifier.check(callee)) {
|
||||
return callee.name === "forwardRef";
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function extractForwardRefTypes(
|
||||
init: Expression | SpreadElement | JSXNamespacedName,
|
||||
j: JSCodeshift
|
||||
) {
|
||||
if (!isReactForwardRef(init, j)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!j.CallExpression.check(init) || !("typeParameters" in init)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const typeParameters = init.typeParameters;
|
||||
|
||||
// If no type parameters, we still want to apply default empty object for props
|
||||
if (
|
||||
!j.TSTypeParameterInstantiation.check(typeParameters) ||
|
||||
typeParameters.params.length === 0
|
||||
) {
|
||||
return; // Let the default props type handling take care of it
|
||||
}
|
||||
|
||||
const typeParams = typeParameters.params;
|
||||
|
||||
// React.forwardRef<ElementType, PropsType>
|
||||
// If PropsType is not specified, use Record<string, unknown> to avoid ESLint errors
|
||||
const [elementType] = typeParams;
|
||||
|
||||
if (!elementType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const propsType =
|
||||
typeParams.length >= 2 && typeParams[1]
|
||||
? typeParams[1]
|
||||
: j.tsTypeReference(
|
||||
j.identifier("Record"),
|
||||
j.tsTypeParameterInstantiation([
|
||||
j.tsStringKeyword(),
|
||||
j.tsUnknownKeyword(),
|
||||
])
|
||||
);
|
||||
|
||||
// Create React.ForwardedRef<ElementType> for the ref parameter
|
||||
const refType = j.tsTypeReference(
|
||||
j.tsQualifiedName(j.identifier("React"), j.identifier("ForwardedRef")),
|
||||
j.tsTypeParameterInstantiation([elementType])
|
||||
);
|
||||
|
||||
return { propsType, refType };
|
||||
}
|
||||
|
||||
function isEmptyObjectType(type: TSType, j: JSCodeshift) {
|
||||
return j.TSTypeLiteral.check(type) && type.members.length === 0;
|
||||
}
|
||||
|
||||
function convertToFunction(
|
||||
j: JSCodeshift,
|
||||
declaration: VariableDeclarator,
|
||||
init: Expression | SpreadElement | JSXNamespacedName,
|
||||
propsType: TSType | null | undefined
|
||||
) {
|
||||
if (!j.Identifier.check(declaration.id)) {
|
||||
throw new Error("Declaration id must be an identifier");
|
||||
}
|
||||
const componentName = declaration.id.name;
|
||||
const arrowFn = extractArrowFunction(init, j);
|
||||
|
||||
if (!arrowFn) {
|
||||
throw new Error("Expected ArrowFunctionExpression or FunctionExpression");
|
||||
}
|
||||
|
||||
const params = arrowFn.params;
|
||||
const body = toBlockBody(j, arrowFn.body);
|
||||
|
||||
const newFunction = j.functionDeclaration(
|
||||
j.identifier(componentName),
|
||||
params,
|
||||
body
|
||||
);
|
||||
|
||||
// Check if this is React.forwardRef and extract types for props and ref
|
||||
const forwardRefTypes = extractForwardRefTypes(init, j);
|
||||
|
||||
if (forwardRefTypes) {
|
||||
// Apply props type to first parameter
|
||||
const [firstParam, secondParam] = newFunction.params;
|
||||
if (j.Pattern.check(firstParam) && "typeAnnotation" in firstParam) {
|
||||
ensureParamType(firstParam, forwardRefTypes.propsType, j);
|
||||
}
|
||||
// Apply ref type to second parameter
|
||||
if (j.Pattern.check(secondParam) && "typeAnnotation" in secondParam) {
|
||||
ensureParamType(secondParam, forwardRefTypes.refType, j);
|
||||
}
|
||||
} else if (newFunction.params.length > 0) {
|
||||
const [firstParam] = newFunction.params;
|
||||
if (firstParam) {
|
||||
ensureParamType(firstParam, propsType, j);
|
||||
}
|
||||
} else if (propsType && !isEmptyObjectType(propsType, j)) {
|
||||
// If there are no params but a non-empty propsType exists, add _props parameter
|
||||
const propsParam = j.identifier("_props");
|
||||
ensureParamType(propsParam, propsType, j);
|
||||
newFunction.params.push(propsParam);
|
||||
}
|
||||
|
||||
if (arrowFn.returnType) {
|
||||
newFunction.returnType = arrowFn.returnType;
|
||||
}
|
||||
|
||||
// Preserve type parameters (generics) from arrow function
|
||||
if (arrowFn.typeParameters) {
|
||||
newFunction.typeParameters = arrowFn.typeParameters;
|
||||
}
|
||||
|
||||
newFunction.async = arrowFn.async;
|
||||
newFunction.generator = arrowFn.generator;
|
||||
|
||||
return newFunction;
|
||||
}
|
||||
|
||||
function containsJsx(j: JSCodeshift, body: ASTNode) {
|
||||
return (
|
||||
j(body).find(j.JSXElement).paths().length > 0 ||
|
||||
j(body).find(j.JSXFragment).paths().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function toFunctionExpression(
|
||||
j: JSCodeshift,
|
||||
declaration: FunctionDeclaration
|
||||
) {
|
||||
const expression = j.functionExpression(
|
||||
declaration.id,
|
||||
declaration.params,
|
||||
declaration.body,
|
||||
declaration.generator,
|
||||
declaration.async
|
||||
);
|
||||
expression.returnType = declaration.returnType;
|
||||
expression.typeParameters = declaration.typeParameters;
|
||||
return expression;
|
||||
}
|
||||
|
||||
export default function transform(file: FileInfo, api: API, options: Options) {
|
||||
const baseJ = api.jscodeshift;
|
||||
const j =
|
||||
typeof baseJ.withParser === "function" ? baseJ.withParser("tsx") : baseJ;
|
||||
const root = j(file.source);
|
||||
|
||||
root
|
||||
.find(j.VariableDeclaration)
|
||||
.filter((path) => {
|
||||
const [firstDeclaration] = path.node.declarations;
|
||||
|
||||
if (!j.VariableDeclarator.check(firstDeclaration)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!j.Identifier.check(firstDeclaration.id) ||
|
||||
!isComponentNameIdentifier(firstDeclaration.id)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const init = firstDeclaration.init;
|
||||
|
||||
if (!init) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const functionToCheck = extractArrowFunction(init, j);
|
||||
|
||||
if (!functionToCheck) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.path && !file.path.endsWith(".tsx")) {
|
||||
if (!containsJsx(j, functionToCheck)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.forEach((path) => {
|
||||
const [firstDeclaration] = path.node.declarations;
|
||||
if (!j.VariableDeclarator.check(firstDeclaration)) {
|
||||
return;
|
||||
}
|
||||
const init = firstDeclaration.init;
|
||||
|
||||
if (!init) {
|
||||
return;
|
||||
}
|
||||
|
||||
let typeAnnotation: ASTNode | null | undefined;
|
||||
if (j.Identifier.check(firstDeclaration.id)) {
|
||||
typeAnnotation = firstDeclaration.id.typeAnnotation?.typeAnnotation;
|
||||
}
|
||||
|
||||
// Try to get props type from variable type annotation first
|
||||
let propsType: TSType | undefined = undefined;
|
||||
|
||||
if (
|
||||
j.TSTypeReference.check(typeAnnotation) &&
|
||||
isReactComponentType(typeAnnotation, j)
|
||||
) {
|
||||
propsType = typeAnnotation.typeParameters?.params?.[0];
|
||||
}
|
||||
|
||||
// If no props type from variable annotation, try to extract from wrapper's type parameters
|
||||
if (!propsType) {
|
||||
propsType = extractPropsTypeFromWrapper(init, j);
|
||||
}
|
||||
|
||||
const newFunction = convertToFunction(
|
||||
j,
|
||||
firstDeclaration,
|
||||
init,
|
||||
propsType
|
||||
);
|
||||
|
||||
const originalNode = path.node;
|
||||
|
||||
// Check if init is wrapped in a call expression (e.g., observer(...))
|
||||
const hasWrapper = j.CallExpression.check(init);
|
||||
|
||||
if (hasWrapper) {
|
||||
// Preserve the wrapper by keeping it as a const assignment
|
||||
// e.g., export const Foo = observer(() => {}) becomes export const Foo = observer(function Foo() {...})
|
||||
|
||||
// Convert function declaration to function expression for wrapping
|
||||
const functionExpression = toFunctionExpression(j, newFunction);
|
||||
|
||||
const wrappedFunction = j.callExpression(init.callee, [
|
||||
functionExpression,
|
||||
...init.arguments.slice(1),
|
||||
]);
|
||||
|
||||
if (!j.Identifier.check(firstDeclaration.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newDeclarator = j.variableDeclarator(
|
||||
j.identifier(firstDeclaration.id.name),
|
||||
wrappedFunction
|
||||
);
|
||||
|
||||
const newVarDecl = j.variableDeclaration("const", [newDeclarator]);
|
||||
|
||||
// Copy comments from original declaration to new function
|
||||
copyOuterComments(originalNode, newVarDecl, j);
|
||||
|
||||
j(path).replaceWith(newVarDecl);
|
||||
return;
|
||||
}
|
||||
|
||||
// Copy outer comments from original declaration to new function
|
||||
copyOuterComments(originalNode, newFunction, j);
|
||||
|
||||
// Copy comments from VariableDeclarator (e.g. export /* comment */ const Foo)
|
||||
if (firstDeclaration.comments) {
|
||||
addComments(newFunction, firstDeclaration.comments);
|
||||
}
|
||||
|
||||
// Copy comments from arrow function
|
||||
if (init.comments) {
|
||||
addComments(newFunction, init.comments);
|
||||
}
|
||||
|
||||
j(path).replaceWith(newFunction);
|
||||
});
|
||||
|
||||
const quote = options.quote ?? '"';
|
||||
|
||||
const source = root.toSource({
|
||||
quote,
|
||||
});
|
||||
|
||||
return source;
|
||||
}
|
||||
176
packages/codemods/headlessui-v2-default-tags.ts
Normal file
176
packages/codemods/headlessui-v2-default-tags.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { API, FileInfo, JSXOpeningElement, Options } from "jscodeshift";
|
||||
|
||||
/**
|
||||
* Headless UI v1 -> v2 default-tag preservation.
|
||||
*
|
||||
* v2 changed the element some components render by default. Every call site that relied on
|
||||
* the v1 default therefore silently changes its DOM — it still compiles and still runs, so
|
||||
* nothing catches it but the eye. Adding an explicit `as=` at those sites pins the emitted
|
||||
* markup to what v1 produced.
|
||||
*
|
||||
* Derived by diffing the `defaultTag` values in the v1.7.19 and v2.2.10 dist bundles:
|
||||
*
|
||||
* bare <Transition> div -> Fragment => as="div"
|
||||
* Combobox.Options ul -> div => as="ul"
|
||||
* Combobox.Option li -> div => as="li"
|
||||
* Listbox.Options ul -> div => as="ul"
|
||||
* Listbox.Option li -> div => as="li"
|
||||
* Tab.Group Fragment -> div => as={Fragment}
|
||||
*
|
||||
* Menu.*, Dialog.*, Disclosure.*, Popover.*, Switch and RadioGroup are byte-identical
|
||||
* between the two versions and are deliberately left alone.
|
||||
*/
|
||||
|
||||
type AsValue = { kind: "string"; value: string } | { kind: "fragment" };
|
||||
|
||||
/** Keyed by `<Object>.<Property>` as written in JSX. */
|
||||
const MEMBER_TARGETS: Record<string, AsValue> = {
|
||||
"Combobox.Options": { kind: "string", value: "ul" },
|
||||
"Combobox.Option": { kind: "string", value: "li" },
|
||||
"Listbox.Options": { kind: "string", value: "ul" },
|
||||
"Listbox.Option": { kind: "string", value: "li" },
|
||||
"Tab.Group": { kind: "fragment" },
|
||||
};
|
||||
|
||||
/** Keyed by the bare JSX identifier. */
|
||||
const IDENTIFIER_TARGETS: Record<string, AsValue> = {
|
||||
Transition: { kind: "string", value: "div" },
|
||||
};
|
||||
|
||||
export default function transform(file: FileInfo, api: API, options: Options) {
|
||||
const j = api.jscodeshift;
|
||||
const root = j(file.source);
|
||||
|
||||
// Only rewrite components this file actually imports from Headless UI — the repo has
|
||||
// first-party components sharing these names (e.g. its own <Transition> wrappers).
|
||||
// Map local name -> imported name so aliases such as `Popover as HeadlessReactPopover`
|
||||
// still resolve to the Headless UI component they came from.
|
||||
const importedNameByLocal = new Map<string, string>();
|
||||
root
|
||||
.find(j.ImportDeclaration, { source: { value: "@headlessui/react" } })
|
||||
.forEach((path) => {
|
||||
for (const specifier of path.node.specifiers ?? []) {
|
||||
if (specifier.type === "ImportSpecifier" && specifier.local) {
|
||||
importedNameByLocal.set(
|
||||
specifier.local.name,
|
||||
specifier.imported.name as string
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (importedNameByLocal.size === 0) return file.source;
|
||||
|
||||
let mutations = 0;
|
||||
let usedFragment = false;
|
||||
|
||||
const resolveTarget = (
|
||||
node: JSXOpeningElement["name"]
|
||||
): AsValue | undefined => {
|
||||
if (node.type === "JSXMemberExpression") {
|
||||
const object = node.object;
|
||||
if (object.type !== "JSXIdentifier") return undefined;
|
||||
const importedName = importedNameByLocal.get(object.name);
|
||||
if (!importedName) return undefined;
|
||||
return MEMBER_TARGETS[`${importedName}.${node.property.name}`];
|
||||
}
|
||||
if (node.type === "JSXIdentifier") {
|
||||
const importedName = importedNameByLocal.get(node.name);
|
||||
if (!importedName) return undefined;
|
||||
return IDENTIFIER_TARGETS[importedName];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
root.find(j.JSXOpeningElement).forEach((path) => {
|
||||
const target = resolveTarget(path.node.name);
|
||||
if (!target) return;
|
||||
|
||||
const attributes = path.node.attributes ?? [];
|
||||
|
||||
// An explicit `as` already pins the tag — including when it sits on a continuation line.
|
||||
const hasExplicitAs = attributes.some(
|
||||
(attribute) =>
|
||||
attribute.type === "JSXAttribute" && attribute.name?.name === "as"
|
||||
);
|
||||
if (hasExplicitAs) return;
|
||||
|
||||
// A spread may carry `as` at runtime; setting it here would override the caller.
|
||||
const hasSpread = attributes.some(
|
||||
(attribute) => attribute.type === "JSXSpreadAttribute"
|
||||
);
|
||||
if (hasSpread) return;
|
||||
|
||||
const value =
|
||||
target.kind === "fragment"
|
||||
? j.jsxExpressionContainer(j.identifier("Fragment"))
|
||||
: j.literal(target.value);
|
||||
|
||||
if (target.kind === "fragment") usedFragment = true;
|
||||
|
||||
attributes.unshift(j.jsxAttribute(j.jsxIdentifier("as"), value));
|
||||
path.node.attributes = attributes;
|
||||
mutations += 1;
|
||||
});
|
||||
|
||||
if (mutations === 0) return file.source;
|
||||
|
||||
if (usedFragment) ensureFragmentImport(j, root);
|
||||
|
||||
return root.toSource(options);
|
||||
}
|
||||
|
||||
/** Adds `Fragment` to the file's react import, creating that import if it has none. */
|
||||
function ensureFragmentImport(
|
||||
j: API["jscodeshift"],
|
||||
root: ReturnType<API["jscodeshift"]>
|
||||
) {
|
||||
const reactImports = root.find(j.ImportDeclaration, {
|
||||
source: { value: "react" },
|
||||
});
|
||||
|
||||
// `Fragment` is usable only when a value specifier binds that exact local name:
|
||||
// `import type { Fragment }` binds no runtime value, and
|
||||
// `import { Fragment as ReactFragment }` binds no local `Fragment` identifier.
|
||||
const alreadyImported = reactImports.nodes().some(
|
||||
(node) =>
|
||||
node.importKind !== "type" &&
|
||||
(node.specifiers ?? []).some(
|
||||
(specifier) =>
|
||||
specifier.type === "ImportSpecifier" &&
|
||||
// `import { type Fragment }`: ast-types typings lack importKind on specifiers.
|
||||
(specifier as { importKind?: string }).importKind !== "type" &&
|
||||
specifier.local?.name === "Fragment"
|
||||
)
|
||||
);
|
||||
if (alreadyImported) return;
|
||||
|
||||
// Prefer a value import; `import type { X } from "react"` cannot carry a runtime binding.
|
||||
const valueImport = reactImports
|
||||
.nodes()
|
||||
.find((node) => node.importKind !== "type");
|
||||
|
||||
if (valueImport) {
|
||||
valueImport.specifiers = valueImport.specifiers ?? [];
|
||||
valueImport.specifiers.push(j.importSpecifier(j.identifier("Fragment")));
|
||||
return;
|
||||
}
|
||||
|
||||
const declaration = j.importDeclaration(
|
||||
[j.importSpecifier(j.identifier("Fragment"))],
|
||||
j.literal("react")
|
||||
);
|
||||
|
||||
const firstImport = root.find(j.ImportDeclaration).at(0);
|
||||
if (firstImport.size() > 0) {
|
||||
firstImport.insertBefore(declaration);
|
||||
} else {
|
||||
root.get().node.program.body.unshift(declaration);
|
||||
}
|
||||
}
|
||||
385
packages/codemods/instructions.md
Normal file
385
packages/codemods/instructions.md
Normal file
@@ -0,0 +1,385 @@
|
||||
# jscodeshift Instructions
|
||||
|
||||
## API Reference
|
||||
|
||||
### Core API
|
||||
|
||||
#### `jscodeshift`
|
||||
|
||||
The main function that returns the jscodeshift instance.
|
||||
|
||||
- **Parameters**: `source` (String)
|
||||
- **Example**:
|
||||
```javascript
|
||||
const j = jscodeshift(sourceCode);
|
||||
```
|
||||
|
||||
### Node Traversal APIs
|
||||
|
||||
#### `find`
|
||||
|
||||
Finds nodes that match the provided type.
|
||||
|
||||
- **Parameters**: `type` (String or Function)
|
||||
- **Example**:
|
||||
```javascript
|
||||
const variableDeclarations = j.find(j.VariableDeclaration);
|
||||
```
|
||||
|
||||
#### `findImportDeclarations`
|
||||
|
||||
Finds all ImportDeclarations optionally filtered by name.
|
||||
|
||||
- **Parameters**: `sourcePath` (String)
|
||||
- **Example**:
|
||||
```javascript
|
||||
const routerImports = j.findImportDeclarations("react-router-dom");
|
||||
```
|
||||
|
||||
#### `closestScope`
|
||||
|
||||
Finds the closest enclosing scope of a node.
|
||||
|
||||
- **Example**:
|
||||
```javascript
|
||||
const closestScopes = j.find(j.Identifier).closestScope();
|
||||
```
|
||||
|
||||
#### `closest`
|
||||
|
||||
Finds the nearest parent node that matches the specified type.
|
||||
|
||||
- **Parameters**: `type` (String or Function)
|
||||
- **Example**:
|
||||
```javascript
|
||||
const closestFunction = j.find(j.Identifier).closest(j.FunctionDeclaration);
|
||||
```
|
||||
|
||||
#### `getVariableDeclarators`
|
||||
|
||||
Retrieves variable declarators from the current collection.
|
||||
|
||||
- **Parameters**: `callback` (Function)
|
||||
- **Example**:
|
||||
```javascript
|
||||
const variableDeclarators = j
|
||||
.find(j.Identifier)
|
||||
.getVariableDeclarators((path) => path.value.name);
|
||||
```
|
||||
|
||||
#### `findVariableDeclarators`
|
||||
|
||||
Finds variable declarators by name.
|
||||
|
||||
- **Parameters**: `name` (String)
|
||||
- **Example**:
|
||||
```javascript
|
||||
const variableDeclarators = j.findVariableDeclarators("a");
|
||||
```
|
||||
|
||||
#### `filter`
|
||||
|
||||
Filters nodes based on a predicate function.
|
||||
|
||||
- **Parameters**: `predicate` (Function)
|
||||
- **Example**:
|
||||
```javascript
|
||||
const constDeclarations = j
|
||||
.find(j.VariableDeclaration)
|
||||
.filter((path) => path.node.kind === "const");
|
||||
```
|
||||
|
||||
#### `forEach`
|
||||
|
||||
Iterates over each node in the collection.
|
||||
|
||||
- **Parameters**: `callback` (Function)
|
||||
- **Example**:
|
||||
```javascript
|
||||
j.find(j.VariableDeclaration).forEach((path) => {
|
||||
console.log(path.node);
|
||||
});
|
||||
```
|
||||
|
||||
#### `some`
|
||||
|
||||
Checks if at least one element in the collection passes the test.
|
||||
|
||||
- **Parameters**: `callback` (Function)
|
||||
- **Example**:
|
||||
```javascript
|
||||
const hasVariableA = root
|
||||
.find(j.VariableDeclarator)
|
||||
.some((path) => path.node.id.name === "a");
|
||||
```
|
||||
|
||||
#### `every`
|
||||
|
||||
Checks if all elements in the collection pass the test.
|
||||
|
||||
- **Parameters**: `callback` (Function)
|
||||
- **Example**:
|
||||
```javascript
|
||||
const allAreConst = root
|
||||
.find(j.VariableDeclaration)
|
||||
.every((path) => path.node.kind === "const");
|
||||
```
|
||||
|
||||
#### `map`
|
||||
|
||||
Maps each node in the collection to a new value.
|
||||
|
||||
- **Parameters**: `callback` (Function)
|
||||
- **Example**:
|
||||
```javascript
|
||||
const variableNames = j
|
||||
.find(j.VariableDeclaration)
|
||||
.map((path) => path.node.declarations.map((decl) => decl.id.name));
|
||||
```
|
||||
|
||||
#### `size`
|
||||
|
||||
Returns the number of nodes in the collection.
|
||||
|
||||
- **Example**:
|
||||
```javascript
|
||||
const numberOfNodes = j.find(j.VariableDeclaration).size();
|
||||
```
|
||||
|
||||
#### `length`
|
||||
|
||||
Returns the number of elements in the collection.
|
||||
|
||||
- **Example**:
|
||||
```javascript
|
||||
const varCount = root.find(j.VariableDeclarator).length;
|
||||
```
|
||||
|
||||
#### `nodes`
|
||||
|
||||
Returns the AST nodes in the collection.
|
||||
|
||||
- **Example**:
|
||||
```javascript
|
||||
const nodes = j.find(j.VariableDeclaration).nodes();
|
||||
```
|
||||
|
||||
#### `paths`
|
||||
|
||||
Returns the paths of the found nodes.
|
||||
|
||||
- **Example**:
|
||||
```javascript
|
||||
const paths = j.find(j.VariableDeclaration).paths();
|
||||
```
|
||||
|
||||
#### `getAST`
|
||||
|
||||
Returns the root AST node of the collection.
|
||||
|
||||
- **Example**:
|
||||
```javascript
|
||||
const ast = root.getAST();
|
||||
```
|
||||
|
||||
#### `get`
|
||||
|
||||
Gets the first node in the collection.
|
||||
|
||||
- **Example**:
|
||||
```javascript
|
||||
const firstVariableDeclaration = j.find(j.VariableDeclaration).get();
|
||||
```
|
||||
|
||||
#### `at`
|
||||
|
||||
Navigates to a specific path in the AST.
|
||||
|
||||
- **Parameters**: `index` (Number)
|
||||
- **Example**:
|
||||
```javascript
|
||||
const secondVariableDeclaration = j.find(j.VariableDeclaration).at(1);
|
||||
```
|
||||
|
||||
#### `getTypes`
|
||||
|
||||
Returns the set of node types present in the collection.
|
||||
|
||||
- **Example**:
|
||||
```javascript
|
||||
const types = root.find(j.VariableDeclarator).getTypes();
|
||||
```
|
||||
|
||||
#### `isOfType`
|
||||
|
||||
Checks if the node in the collection is of a specific type.
|
||||
|
||||
- **Parameters**: `type` (String)
|
||||
- **Example**:
|
||||
```javascript
|
||||
const isVariableDeclarator = root
|
||||
.find(j.VariableDeclarator)
|
||||
.at(0)
|
||||
.isOfType("VariableDeclarator");
|
||||
```
|
||||
|
||||
### Node Transformation APIs
|
||||
|
||||
#### `replaceWith`
|
||||
|
||||
Replaces the current node(s) with a new node.
|
||||
|
||||
- **Parameters**: `newNode` (Node or Function)
|
||||
- **Example**:
|
||||
```javascript
|
||||
j.find(j.Identifier).replaceWith((path) =>
|
||||
j.identifier(path.node.name.toUpperCase())
|
||||
);
|
||||
```
|
||||
|
||||
#### `insertBefore`
|
||||
|
||||
Inserts a node before the current node.
|
||||
|
||||
- **Parameters**: `newNode` (Node)
|
||||
- **Example**:
|
||||
```javascript
|
||||
j.find(j.FunctionDeclaration).insertBefore(
|
||||
j.expressionStatement(j.stringLiteral("Inserted before"))
|
||||
);
|
||||
```
|
||||
|
||||
#### `insertAfter`
|
||||
|
||||
Inserts a node after the current node.
|
||||
|
||||
- **Parameters**: `newNode` (Node)
|
||||
- **Example**:
|
||||
```javascript
|
||||
j.find(j.FunctionDeclaration).insertAfter(
|
||||
j.expressionStatement(j.stringLiteral("Inserted after"))
|
||||
);
|
||||
```
|
||||
|
||||
#### `remove`
|
||||
|
||||
Removes the current node(s).
|
||||
|
||||
- **Example**:
|
||||
```javascript
|
||||
j.find(j.VariableDeclaration).remove();
|
||||
```
|
||||
|
||||
#### `renameTo`
|
||||
|
||||
Renames the nodes in the collection to a new name.
|
||||
|
||||
- **Parameters**: `newName` (String)
|
||||
- **Example**:
|
||||
```javascript
|
||||
root.find(j.Identifier, { name: "a" }).renameTo("x");
|
||||
```
|
||||
|
||||
#### `toSource`
|
||||
|
||||
Converts the transformed AST back to source code.
|
||||
|
||||
- **Parameters**: `options` (Object)
|
||||
- **Example**:
|
||||
```javascript
|
||||
const transformedSource = j.toSource({ quote: "single" });
|
||||
```
|
||||
|
||||
## AST Grammar
|
||||
|
||||
jscodeshift provides 278 node types which are mapped to their corresponding node type in `ast-types`.
|
||||
|
||||
### Common Node Types
|
||||
|
||||
- **AnyTypeAnnotation**: A type annotation representing any type.
|
||||
- **ArrayExpression**: Represents an array literal.
|
||||
- **ArrayPattern**: A pattern that matches an array from a destructuring assignment.
|
||||
- **ArrayTypeAnnotation**: A type annotation for arrays.
|
||||
- **ArrowFunctionExpression**: An arrow function expression.
|
||||
- **AssignmentExpression**: Represents an assignment expression.
|
||||
- **AssignmentPattern**: A pattern that matches an assignment from a destructuring assignment.
|
||||
- **AwaitExpression**: Represents an await expression.
|
||||
- **BigIntLiteral**: A literal representing a big integer.
|
||||
- **BinaryExpression**: Represents a binary expression.
|
||||
- **BlockStatement**: Represents a block statement.
|
||||
- **BooleanLiteral**: A literal representing a boolean value.
|
||||
- **BreakStatement**: Represents a break statement.
|
||||
- **CallExpression**: Represents a call expression.
|
||||
- **CatchClause**: Represents a catch clause in a try statement.
|
||||
- **ClassDeclaration**: Represents a class declaration.
|
||||
- **ClassExpression**: Represents a class expression.
|
||||
- **ClassMethod**: Represents a method of a class.
|
||||
- **ClassProperty**: Represents a property of a class.
|
||||
- **Comment**: Represents a comment in the code.
|
||||
- **ConditionalExpression**: Represents a conditional expression (ternary).
|
||||
- **ContinueStatement**: Represents a continue statement.
|
||||
- **DebuggerStatement**: Represents a debugger statement.
|
||||
- **Declaration**: Represents a declaration in the code.
|
||||
- **DoWhileStatement**: Represents a do…while statement.
|
||||
- **ExportAllDeclaration**: Represents an export all declaration.
|
||||
- **ExportDeclaration**: Represents an export declaration.
|
||||
- **ExportDefaultDeclaration**: Represents an export default declaration.
|
||||
- **ExportNamedDeclaration**: Represents a named export declaration.
|
||||
- **ExpressionStatement**: Represents an expression statement.
|
||||
- **File**: Represents a file in the AST.
|
||||
- **ForInStatement**: Represents a for-in statement.
|
||||
- **ForOfStatement**: Represents a for-of statement.
|
||||
- **ForStatement**: Represents a for statement.
|
||||
- **FunctionDeclaration**: Represents a function declaration.
|
||||
- **FunctionExpression**: Represents a function expression.
|
||||
- **Identifier**: Represents an identifier.
|
||||
- **IfStatement**: Represents an if statement.
|
||||
- **ImportDeclaration**: Represents an import declaration.
|
||||
- **ImportDefaultSpecifier**: Represents a default import specifier.
|
||||
- **ImportNamespaceSpecifier**: Represents a namespace import specifier.
|
||||
- **ImportSpecifier**: Represents an import specifier.
|
||||
- **InterfaceDeclaration**: Represents an interface declaration.
|
||||
- **JSXAttribute**: Represents an attribute in a JSX element.
|
||||
- **JSXElement**: Represents a JSX element.
|
||||
- **JSXExpressionContainer**: Represents an expression container in JSX.
|
||||
- **JSXFragment**: Represents a JSX fragment.
|
||||
- **JSXIdentifier**: Represents an identifier in JSX.
|
||||
- **JSXText**: Represents text in JSX.
|
||||
- **Literal**: Represents a literal value.
|
||||
- **LogicalExpression**: Represents a logical expression.
|
||||
- **MemberExpression**: Represents a member expression.
|
||||
- **MethodDefinition**: Represents a method definition.
|
||||
- **NewExpression**: Represents a new expression.
|
||||
- **ObjectExpression**: Represents an object expression.
|
||||
- **ObjectPattern**: Represents an object pattern for destructuring.
|
||||
- **ObjectProperty**: Represents a property in an object.
|
||||
- **Program**: Represents the entire program.
|
||||
- **Property**: Represents a property in an object.
|
||||
- **ReturnStatement**: Represents a return statement.
|
||||
- **SpreadElement**: Represents a spread element in an array or function call.
|
||||
- **StringLiteral**: Represents a string literal.
|
||||
- **SwitchCase**: Represents a case in a switch statement.
|
||||
- **SwitchStatement**: Represents a switch statement.
|
||||
- **TemplateLiteral**: Represents a template literal.
|
||||
- **ThisExpression**: Represents the `this` expression.
|
||||
- **ThrowStatement**: Represents a throw statement.
|
||||
- **TryStatement**: Represents a try statement.
|
||||
- **TSAnyKeyword**: Represents the TypeScript `any` keyword.
|
||||
- **TSArrayType**: Represents a TypeScript array type.
|
||||
- **TSAsExpression**: Represents a TypeScript as-expression.
|
||||
- **TSBooleanKeyword**: Represents the TypeScript `boolean` keyword.
|
||||
- **TSDeclareFunction**: Represents a TypeScript function declaration.
|
||||
- **TSEnumDeclaration**: Represents a TypeScript enum declaration.
|
||||
- **TSInterfaceDeclaration**: Represents a TypeScript interface declaration.
|
||||
- **TSNumberKeyword**: Represents the TypeScript `number` keyword.
|
||||
- **TSStringKeyword**: Represents the TypeScript `string` keyword.
|
||||
- **TSTypeAliasDeclaration**: Represents a TypeScript type alias declaration.
|
||||
- **TSTypeAnnotation**: Represents a TypeScript type annotation.
|
||||
- **TSTypeReference**: Represents a type reference in TypeScript.
|
||||
- **TSUnionType**: Represents a union type in TypeScript.
|
||||
- **UnaryExpression**: Represents a unary expression.
|
||||
- **VariableDeclaration**: Represents a variable declaration.
|
||||
- **VariableDeclarator**: Represents a variable declarator.
|
||||
- **WhileStatement**: Represents a while statement.
|
||||
|
||||
For a complete list and detailed structure of each node, refer to the [AST Grammar documentation](https://jscodeshift.com/build/ast-grammar/).
|
||||
18
packages/codemods/package.json
Normal file
18
packages/codemods/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@plane/codemods",
|
||||
"version": "1.4.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"function-declaration": "jscodeshift -t ./function-declaration.ts --extensions=tsx --parser=tsx ../../apps/*/app ../../apps/*/ce ../../apps/*/core ../../apps/*/ee ../../apps/*/helpers ../../packages/*/src --ignore-pattern='**/node_modules/**' --ignore-pattern='**/dist/**' --ignore-pattern='**/build/**' --ignore-pattern='**/*.d.ts'",
|
||||
"remove-directives": "jscodeshift -t ./remove-directives.ts --extensions=ts,tsx --parser=tsx ../../apps/*/app ../../apps/*/ce ../../apps/*/core ../../apps/*/ee ../../apps/*/helpers ../../apps/*/src ../../packages/*/src --ignore-pattern='**/node_modules/**' --ignore-pattern='**/dist/**' --ignore-pattern='**/build/**' --ignore-pattern='**/*.d.ts'",
|
||||
"headlessui-v2-default-tags": "jscodeshift -t ./headlessui-v2-default-tags.ts --extensions=tsx --parser=tsx ../../apps/*/app ../../apps/*/ce ../../apps/*/components ../../apps/*/core ../../apps/*/ee ../../apps/*/helpers ../../apps/*/src ../../packages/*/src --ignore-pattern='**/node_modules/**' --ignore-pattern='**/dist/**' --ignore-pattern='**/build/**' --ignore-pattern='**/*.d.ts'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hypermod/utils": "catalog:",
|
||||
"@types/jscodeshift": "catalog:",
|
||||
"ast-types": "catalog:",
|
||||
"jscodeshift": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
103
packages/codemods/remove-directives.ts
Normal file
103
packages/codemods/remove-directives.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { API, FileInfo, Options } from "jscodeshift";
|
||||
import type { CommentKind, DirectiveKind } from "ast-types/gen/kinds";
|
||||
|
||||
export default function transform(file: FileInfo, api: API, options: Options) {
|
||||
const j = api.jscodeshift;
|
||||
const root = j(file.source);
|
||||
|
||||
const directivesToRemove = new Set(["use client", "use server", "use-client", "use-server"]);
|
||||
|
||||
root.find(j.Program).forEach((path) => {
|
||||
// Handle ASTs where directives are stored in 'directives' property (Babel/TS)
|
||||
if (path.node.directives) {
|
||||
const newDirectives: DirectiveKind[] = [];
|
||||
let capturedComments: CommentKind[] = [];
|
||||
|
||||
path.node.directives.forEach((directive) => {
|
||||
if (directive.type === 'Directive' && directive.value.type === 'DirectiveLiteral' && typeof directive.value.value === 'string' && directivesToRemove.has(directive.value.value)) {
|
||||
// Directive is being removed. Capture its comments.
|
||||
if (directive.comments) {
|
||||
capturedComments.push(...directive.comments);
|
||||
}
|
||||
} else {
|
||||
// Keep this directive
|
||||
// If we have captured comments from previous removed directives, attach them here?
|
||||
// Usually comments belong to the next node.
|
||||
if (capturedComments.length > 0) {
|
||||
directive.comments = directive.comments || [];
|
||||
directive.comments.unshift(...capturedComments);
|
||||
capturedComments = [];
|
||||
}
|
||||
newDirectives.push(directive);
|
||||
}
|
||||
});
|
||||
|
||||
path.node.directives = newDirectives;
|
||||
|
||||
// If we still have captured comments (e.g. all directives removed, or last one removed),
|
||||
// attach them to the first body node.
|
||||
if (capturedComments.length > 0) {
|
||||
if (path.node.body.length > 0) {
|
||||
const firstBodyNode = path.node.body[0];
|
||||
if (firstBodyNode) {
|
||||
firstBodyNode.comments = firstBodyNode.comments || [];
|
||||
firstBodyNode.comments.unshift(...capturedComments);
|
||||
}
|
||||
} else {
|
||||
// If empty body, attach to Program?
|
||||
// jscodeshift might not print Program comments easily if they are not attached to children.
|
||||
// But let's try attaching to the Program node itself if possible, or leave them (they might be lost).
|
||||
path.node.comments = path.node.comments || [];
|
||||
path.node.comments.push(...capturedComments);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also handle the case where they might be in body as ExpressionStatements
|
||||
if (path.node.body) {
|
||||
const newBody: any[] = [];
|
||||
let capturedComments: any[] = [];
|
||||
|
||||
path.node.body.forEach((node) => {
|
||||
let shouldRemove = false;
|
||||
if (j.ExpressionStatement.check(node)) {
|
||||
const expression = node.expression;
|
||||
if (j.StringLiteral.check(expression) && directivesToRemove.has(expression.value)) {
|
||||
shouldRemove = true;
|
||||
} else if (j.Literal.check(expression) && typeof expression.value === 'string' && directivesToRemove.has(expression.value)) {
|
||||
shouldRemove = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRemove) {
|
||||
if (node.comments) {
|
||||
capturedComments.push(...node.comments);
|
||||
}
|
||||
} else {
|
||||
if (capturedComments.length > 0) {
|
||||
node.comments = node.comments || [];
|
||||
node.comments.unshift(...capturedComments);
|
||||
capturedComments = [];
|
||||
}
|
||||
newBody.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
// If comments left at the end (e.g. removed last statement), usually they are trailing comments of the file.
|
||||
// But if we removed the ONLY statement, they become dangling.
|
||||
// We attached to next node, so if we are at the end, there is no next node.
|
||||
// If we removed a directive at the top, and there are more statements, they got attached.
|
||||
|
||||
path.node.body = newBody;
|
||||
}
|
||||
});
|
||||
|
||||
return root.toSource(options);
|
||||
}
|
||||
|
||||
572
packages/codemods/tests/function-declaration.spec.ts
Normal file
572
packages/codemods/tests/function-declaration.spec.ts
Normal file
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { applyTransform } from "@hypermod/utils";
|
||||
import * as transformer from "../function-declaration";
|
||||
|
||||
describe("function-declaration", () => {
|
||||
it("should convert arrow function components to function declarations", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
import React from "react";
|
||||
|
||||
export const MyComponent: React.FC<{}> = () => {
|
||||
return <div>Hello, world!</div>;
|
||||
};
|
||||
`,
|
||||
{ parser: "tsx" }
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"import React from "react";
|
||||
|
||||
export function MyComponent() {
|
||||
return <div>Hello, world!</div>;
|
||||
}"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should handle components with props", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
import React from "react";
|
||||
|
||||
interface IMyComponentProps {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const MyComponent: React.FC<IMyComponentProps> = ({ name }) => {
|
||||
return <div>Hello, {name}!</div>;
|
||||
};
|
||||
`,
|
||||
{ parser: "tsx" }
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"import React from "react";
|
||||
|
||||
interface IMyComponentProps {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function MyComponent(
|
||||
{
|
||||
name
|
||||
}: IMyComponentProps
|
||||
) {
|
||||
return <div>Hello, {name}!</div>;
|
||||
}"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should preserve default props", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
import React from "react";
|
||||
|
||||
interface IMyComponentProps {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export const MyComponent: React.FC<IMyComponentProps> = ({ name = "world" }) => {
|
||||
return <div>Hello, {name}!</div>;
|
||||
};
|
||||
`,
|
||||
{ parser: "tsx" }
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"import React from "react";
|
||||
|
||||
interface IMyComponentProps {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export function MyComponent(
|
||||
{
|
||||
name = "world"
|
||||
}: IMyComponentProps
|
||||
) {
|
||||
return <div>Hello, {name}!</div>;
|
||||
}"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should not transform non-component arrow functions", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
const myFunction = () => {
|
||||
return "hello";
|
||||
};
|
||||
`,
|
||||
{ parser: "tsx" }
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"const myFunction = () => {
|
||||
return "hello";
|
||||
};"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should handle observer-wrapped components", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
export const WorkspaceAnalyticsHeader = observer(() => {
|
||||
return <div>Analytics</div>;
|
||||
});
|
||||
`,
|
||||
{ parser: "tsx" }
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"import { observer } from "mobx-react";
|
||||
|
||||
export const WorkspaceAnalyticsHeader = observer(function WorkspaceAnalyticsHeader() {
|
||||
return <div>Analytics</div>;
|
||||
});"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should handle inline arrow function components", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
export const StarUsOnGitHubLink = () => {
|
||||
return <a href="https://github.com">Star us</a>;
|
||||
};
|
||||
`,
|
||||
{ parser: "tsx" }
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"export function StarUsOnGitHubLink() {
|
||||
return <a href="https://github.com">Star us</a>;
|
||||
}"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should handle React.FC type without generics", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
import type { FC } from "react";
|
||||
|
||||
export const ProjectAppSidebar: FC = observer(() => {
|
||||
return <div>Sidebar</div>;
|
||||
});
|
||||
`,
|
||||
{ parser: "tsx" }
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"import type { FC } from "react";
|
||||
|
||||
export const ProjectAppSidebar = observer(function ProjectAppSidebar() {
|
||||
return <div>Sidebar</div>;
|
||||
});"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should handle inline JSX arrow function", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
export const DateAlert = (props: TDateAlertProps) => <></>;
|
||||
`,
|
||||
{ parser: "tsx" }
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"export function DateAlert(props: TDateAlertProps) {
|
||||
return <></>;
|
||||
}"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should handle observer with generic type parameters", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
export const InstanceProvider = observer<React.FC<React.PropsWithChildren>>((props) => {
|
||||
const { children } = props;
|
||||
return <>{children}</>;
|
||||
});
|
||||
`,
|
||||
{ parser: "tsx" }
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"import { observer } from "mobx-react";
|
||||
|
||||
export const InstanceProvider = observer(function InstanceProvider(props: React.PropsWithChildren) {
|
||||
const { children } = props;
|
||||
return <>{children}</>;
|
||||
});"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should not add double semicolons after use client directive", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
"use client";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
export const MyComponent = observer(() => {
|
||||
return <div>Hello</div>;
|
||||
});
|
||||
`,
|
||||
{ parser: "tsx" }
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
""use client";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
export const MyComponent = observer(function MyComponent() {
|
||||
return <div>Hello</div>;
|
||||
});"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should preserve generic type parameters in wrapper functions", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
import React from "react";
|
||||
|
||||
export const ScatterChart = React.memo(<K extends string, T extends string>(props: TScatterChartProps<K, T>) => {
|
||||
return <div>Chart</div>;
|
||||
});
|
||||
`,
|
||||
{ parser: "tsx" }
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"import React from "react";
|
||||
|
||||
export const ScatterChart = React.memo(
|
||||
function ScatterChart<K extends string, T extends string>(props: TScatterChartProps<K, T>) {
|
||||
return <div>Chart</div>;
|
||||
}
|
||||
);"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should preserve generic type parameters on React.forwardRef", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
import React from "react";
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
|
||||
return <button ref={ref}>Click me</button>;
|
||||
});
|
||||
`,
|
||||
{ parser: "tsx" }
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"import React from "react";
|
||||
|
||||
const Button = React.forwardRef(
|
||||
function Button(props: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) {
|
||||
return <button ref={ref}>Click me</button>;
|
||||
}
|
||||
);"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should prefix unused props parameter with underscore", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
import type { TCallbackMentionComponentProps } from "@plane/editor";
|
||||
|
||||
export const EditorAdditionalMentionsRoot: React.FC<TCallbackMentionComponentProps> = () => null;
|
||||
`,
|
||||
{ parser: "tsx" }
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"import type { TCallbackMentionComponentProps } from "@plane/editor";
|
||||
|
||||
export function EditorAdditionalMentionsRoot(_props: TCallbackMentionComponentProps) {
|
||||
return null;
|
||||
}"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should add Record<string, unknown> type for React.forwardRef with only element type", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
import { forwardRef } from "react";
|
||||
|
||||
const ListLoaderItemRow = forwardRef<HTMLDivElement>((props, ref) => (
|
||||
<div ref={ref}>Content</div>
|
||||
));
|
||||
`,
|
||||
{ parser: "tsx" }
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"import { forwardRef } from "react";
|
||||
|
||||
const ListLoaderItemRow = forwardRef(
|
||||
function ListLoaderItemRow(props: Record<string, unknown>, ref: React.ForwardedRef<HTMLDivElement>) {
|
||||
return (<div ref={ref}>Content</div>);
|
||||
}
|
||||
);"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should preserve comments in function body", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
export const PreloadResources = () => (
|
||||
// usePreloadResources();
|
||||
null
|
||||
);
|
||||
`,
|
||||
{ parser: "tsx" }
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"export function PreloadResources() {
|
||||
return (
|
||||
// usePreloadResources();
|
||||
(null)
|
||||
);
|
||||
}"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should preserve leading comments before export declaration", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
"use client";
|
||||
|
||||
// TODO: Check if we need this
|
||||
// https://nextjs.org/docs/app/api-reference/functions/generate-metadata#link-relpreload
|
||||
// export const usePreloadResources = () => {
|
||||
// useEffect(() => {
|
||||
// const preloadItem = (url: string) => {
|
||||
// ReactDOM.preload(url, { as: "fetch", crossOrigin: "use-credentials" });
|
||||
// };
|
||||
//
|
||||
// const urls = [
|
||||
// \`\${process.env.VITE_API_BASE_URL}/api/instances/\`,
|
||||
// \`\${process.env.VITE_API_BASE_URL}/api/users/me/\`,
|
||||
// \`\${process.env.VITE_API_BASE_URL}/api/users/me/profile/\`,
|
||||
// \`\${process.env.VITE_API_BASE_URL}/api/users/me/settings/\`,
|
||||
// \`\${process.env.VITE_API_BASE_URL}/api/users/me/workspaces/?v=\${Date.now()}\`,
|
||||
// ];
|
||||
//
|
||||
// urls.forEach((url) => preloadItem(url));
|
||||
// }, []);
|
||||
// };
|
||||
|
||||
export const PreloadResources = () =>
|
||||
// usePreloadResources();
|
||||
null;
|
||||
`,
|
||||
{ parser: "tsx" }
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
""use client";
|
||||
|
||||
// TODO: Check if we need this
|
||||
// https://nextjs.org/docs/app/api-reference/functions/generate-metadata#link-relpreload
|
||||
// export const usePreloadResources = () => {
|
||||
// useEffect(() => {
|
||||
// const preloadItem = (url: string) => {
|
||||
// ReactDOM.preload(url, { as: "fetch", crossOrigin: "use-credentials" });
|
||||
// };
|
||||
//
|
||||
// const urls = [
|
||||
// \`\${process.env.VITE_API_BASE_URL}/api/instances/\`,
|
||||
// \`\${process.env.VITE_API_BASE_URL}/api/users/me/\`,
|
||||
// \`\${process.env.VITE_API_BASE_URL}/api/users/me/profile/\`,
|
||||
// \`\${process.env.VITE_API_BASE_URL}/api/users/me/settings/\`,
|
||||
// \`\${process.env.VITE_API_BASE_URL}/api/users/me/workspaces/?v=\${Date.now()}\`,
|
||||
// ];
|
||||
//
|
||||
// urls.forEach((url) => preloadItem(url));
|
||||
// }, []);
|
||||
// };
|
||||
|
||||
export function PreloadResources() {
|
||||
return (
|
||||
// usePreloadResources();
|
||||
null
|
||||
);
|
||||
}"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should preserve leading comments before wrapped export declaration", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
// This is a wrapped component
|
||||
// It uses observer for reactivity
|
||||
export const MyObserverComponent = observer(() => {
|
||||
return <div>Observer component</div>;
|
||||
});
|
||||
`,
|
||||
{ parser: "tsx" }
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"// This is a wrapped component
|
||||
// It uses observer for reactivity
|
||||
export const MyObserverComponent = observer(function MyObserverComponent() {
|
||||
return <div>Observer component</div>;
|
||||
});"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should preserve trailing comments on exported variable declaration", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
export const Foo = () => <div />; // trailing comment
|
||||
`,
|
||||
{ parser: "tsx" }
|
||||
);
|
||||
|
||||
expect(result).toContain("// trailing comment");
|
||||
});
|
||||
|
||||
it("should preserve leading comments on exported variable declaration inside export", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
export /* leading comment */ const Foo = () => <div />;
|
||||
`,
|
||||
{ parser: "tsx" }
|
||||
);
|
||||
|
||||
expect(result).toContain("/* leading comment */");
|
||||
});
|
||||
|
||||
it("should preserve dependency arrays when transforming wrapped components", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
import { useMemo } from "react";
|
||||
|
||||
const MyComponent = useMemo(() => {
|
||||
return () => <div>Hello</div>;
|
||||
}, [dep]);
|
||||
`,
|
||||
{ parser: "tsx", path: "file.tsx" }
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"import { useMemo } from "react";
|
||||
|
||||
const MyComponent = useMemo(function MyComponent() {
|
||||
return () => <div>Hello</div>;
|
||||
}, [dep]);"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should preserve dependency arrays for constants that look like components", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
import { useMemo } from "react";
|
||||
|
||||
const ACTION_HANDLERS = useMemo(function ACTION_HANDLERS() {
|
||||
return {
|
||||
archived: () => {},
|
||||
};
|
||||
}, []);
|
||||
`,
|
||||
{ parser: "tsx", path: "file.tsx" }
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"import { useMemo } from "react";
|
||||
|
||||
const ACTION_HANDLERS = useMemo(function ACTION_HANDLERS() {
|
||||
return {
|
||||
archived: () => {},
|
||||
};
|
||||
}, []);"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should handle memo with generic type arguments correctly", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
import { memo } from "react";
|
||||
|
||||
type TAccessMenuProps = {
|
||||
currentAccess: number;
|
||||
};
|
||||
|
||||
export const AccessMenu = memo<TAccessMenuProps>(
|
||||
({ currentAccess }) => {
|
||||
return <div>{currentAccess}</div>;
|
||||
}
|
||||
);
|
||||
`,
|
||||
{ parser: "tsx" }
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"import { memo } from "react";
|
||||
|
||||
type TAccessMenuProps = {
|
||||
currentAccess: number;
|
||||
};
|
||||
|
||||
export const AccessMenu = memo(function AccessMenu(
|
||||
{
|
||||
currentAccess
|
||||
}: TAccessMenuProps
|
||||
) {
|
||||
return <div>{currentAccess}</div>;
|
||||
});"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should not transform CONSTANT_CASE variables", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
export const MY_CONSTANT = () => {
|
||||
return "value";
|
||||
};
|
||||
`,
|
||||
{ parser: "tsx" }
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"export const MY_CONSTANT = () => {
|
||||
return "value";
|
||||
};"
|
||||
`);
|
||||
});
|
||||
});
|
||||
201
packages/codemods/tests/headlessui-v2-default-tags.spec.ts
Normal file
201
packages/codemods/tests/headlessui-v2-default-tags.spec.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { applyTransform } from "@hypermod/utils";
|
||||
import * as transformer from "../headlessui-v2-default-tags";
|
||||
|
||||
const apply = (source: string) =>
|
||||
applyTransform(transformer, source, { parser: "tsx" });
|
||||
|
||||
describe("headlessui-v2-default-tags", () => {
|
||||
it("pins a bare <Transition> to the v1 div default", async () => {
|
||||
const result = await apply(`
|
||||
import { Transition } from "@headlessui/react";
|
||||
|
||||
export const Panel = () => (
|
||||
<Transition show={open} className="grid">
|
||||
<div>body</div>
|
||||
</Transition>
|
||||
);
|
||||
`);
|
||||
|
||||
expect(result).toContain(
|
||||
`<Transition as="div" show={open} className="grid">`
|
||||
);
|
||||
});
|
||||
|
||||
it("pins Combobox.Options to ul and Combobox.Option to li", async () => {
|
||||
const result = await apply(`
|
||||
import { Combobox } from "@headlessui/react";
|
||||
|
||||
export const Select = () => (
|
||||
<Combobox.Options className="list">
|
||||
<Combobox.Option value="a">A</Combobox.Option>
|
||||
</Combobox.Options>
|
||||
);
|
||||
`);
|
||||
|
||||
expect(result).toContain(`<Combobox.Options as="ul" className="list">`);
|
||||
expect(result).toContain(`<Combobox.Option as="li" value="a">`);
|
||||
});
|
||||
|
||||
it("pins Listbox.Options to ul and Listbox.Option to li", async () => {
|
||||
const result = await apply(`
|
||||
import { Listbox } from "@headlessui/react";
|
||||
|
||||
export const Select = () => (
|
||||
<Listbox.Options>
|
||||
<Listbox.Option value="a">A</Listbox.Option>
|
||||
</Listbox.Options>
|
||||
);
|
||||
`);
|
||||
|
||||
expect(result).toContain(`<Listbox.Options as="ul">`);
|
||||
expect(result).toContain(`<Listbox.Option as="li" value="a">`);
|
||||
});
|
||||
|
||||
it("pins Tab.Group to Fragment and imports Fragment", async () => {
|
||||
const result = await apply(`
|
||||
import { useState } from "react";
|
||||
import { Tab } from "@headlessui/react";
|
||||
|
||||
export const Tabs = () => (
|
||||
<Tab.Group>
|
||||
<Tab.List>
|
||||
<Tab>One</Tab>
|
||||
</Tab.List>
|
||||
</Tab.Group>
|
||||
);
|
||||
`);
|
||||
|
||||
expect(result).toContain("<Tab.Group as={Fragment}>");
|
||||
expect(result).toMatch(/import \{ useState, Fragment \} from "react"/);
|
||||
// Tab.List and Tab are unchanged between v1 and v2.
|
||||
expect(result).toContain("<Tab.List>");
|
||||
expect(result).toContain("<Tab>One</Tab>");
|
||||
});
|
||||
|
||||
it("adds a value Fragment import alongside an existing type-only one", async () => {
|
||||
const result = await apply(`
|
||||
import type { Fragment } from "react";
|
||||
import { Tab } from "@headlessui/react";
|
||||
|
||||
export const Tabs = () => <Tab.Group><Tab.List /></Tab.Group>;
|
||||
`);
|
||||
|
||||
expect(result).toContain("<Tab.Group as={Fragment}>");
|
||||
// The type-only specifier binds no runtime value, so a real value import must appear.
|
||||
expect(result).toMatch(/import \{ Fragment \} from "react"/);
|
||||
});
|
||||
|
||||
it("does not mistake an aliased Fragment import for a local Fragment binding", async () => {
|
||||
const result = await apply(`
|
||||
import { Fragment as ReactFragment } from "react";
|
||||
import { Tab } from "@headlessui/react";
|
||||
|
||||
export const Tabs = () => <Tab.Group><Tab.List /></Tab.Group>;
|
||||
`);
|
||||
|
||||
expect(result).toContain("<Tab.Group as={Fragment}>");
|
||||
expect(result).toMatch(
|
||||
/import \{ Fragment as ReactFragment, Fragment \} from "react"/
|
||||
);
|
||||
});
|
||||
|
||||
it("adds a react import when the file has none", async () => {
|
||||
const result = await apply(`
|
||||
import { Tab } from "@headlessui/react";
|
||||
|
||||
export const Tabs = () => <Tab.Group><Tab.List /></Tab.Group>;
|
||||
`);
|
||||
|
||||
expect(result).toMatch(/import \{ Fragment \} from "react"/);
|
||||
});
|
||||
|
||||
it("leaves an existing explicit as= alone", async () => {
|
||||
const result = await apply(`
|
||||
import { Transition, Combobox } from "@headlessui/react";
|
||||
|
||||
export const Panel = () => (
|
||||
<Transition
|
||||
as={Fragment}
|
||||
show={open}
|
||||
>
|
||||
<Combobox.Options as="div">x</Combobox.Options>
|
||||
</Transition>
|
||||
);
|
||||
`);
|
||||
|
||||
expect(result).toContain("as={Fragment}");
|
||||
expect(result).not.toContain(`as="div" as={Fragment}`);
|
||||
expect(result).toContain(`<Combobox.Options as="div">`);
|
||||
// exactly one `as` per element
|
||||
expect(result.match(/as=/g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("skips elements carrying a spread that could supply as", async () => {
|
||||
const result = await apply(`
|
||||
import { Transition } from "@headlessui/react";
|
||||
|
||||
export const Panel = (props) => <Transition {...props}>x</Transition>;
|
||||
`);
|
||||
|
||||
expect(result).not.toContain("as=");
|
||||
});
|
||||
|
||||
it("ignores same-named components not imported from Headless UI", async () => {
|
||||
const result = await apply(`
|
||||
import { Transition } from "@/components/transition";
|
||||
|
||||
export const Panel = () => <Transition show>x</Transition>;
|
||||
`);
|
||||
|
||||
expect(result).not.toContain("as=");
|
||||
});
|
||||
|
||||
it("does not touch Menu, Dialog, Disclosure or Popover subcomponents", async () => {
|
||||
const result = await apply(`
|
||||
import { Menu, Dialog, Disclosure, Popover } from "@headlessui/react";
|
||||
|
||||
export const All = () => (
|
||||
<>
|
||||
<Menu.Items><Menu.Item>a</Menu.Item></Menu.Items>
|
||||
<Dialog.Panel><Dialog.Title>t</Dialog.Title></Dialog.Panel>
|
||||
<Disclosure.Button /><Disclosure.Panel />
|
||||
<Popover.Button /><Popover.Panel />
|
||||
</>
|
||||
);
|
||||
`);
|
||||
|
||||
expect(result).not.toContain("as=");
|
||||
});
|
||||
|
||||
it("resolves aliased Headless UI imports by their imported name", async () => {
|
||||
const result = await apply(`
|
||||
import { Transition as HeadlessTransition } from "@headlessui/react";
|
||||
|
||||
export const Panel = () => <HeadlessTransition show>x</HeadlessTransition>;
|
||||
`);
|
||||
|
||||
expect(result).toContain(`<HeadlessTransition as="div" show>`);
|
||||
});
|
||||
|
||||
it("resolves aliased member expressions too", async () => {
|
||||
const result = await apply(`
|
||||
import { Combobox as HeadlessCombobox } from "@headlessui/react";
|
||||
|
||||
export const Select = () => (
|
||||
<HeadlessCombobox.Options>
|
||||
<HeadlessCombobox.Option value="a">A</HeadlessCombobox.Option>
|
||||
</HeadlessCombobox.Options>
|
||||
);
|
||||
`);
|
||||
|
||||
expect(result).toContain(`<HeadlessCombobox.Options as="ul">`);
|
||||
expect(result).toContain(`<HeadlessCombobox.Option as="li" value="a">`);
|
||||
});
|
||||
});
|
||||
296
packages/codemods/tests/remove-directives.spec.ts
Normal file
296
packages/codemods/tests/remove-directives.spec.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { applyTransform } from "@hypermod/utils";
|
||||
import * as transformer from "../remove-directives";
|
||||
|
||||
describe("remove-directives", () => {
|
||||
it("should remove 'use client' directive", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
"use client";
|
||||
import React from "react";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return <div>Hello, world!</div>;
|
||||
};
|
||||
`,
|
||||
{ parser: "tsx" },
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"import React from "react";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return <div>Hello, world!</div>;
|
||||
};"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should remove 'use server' directive", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
"use server";
|
||||
import db from "./db";
|
||||
|
||||
export const getData = async () => {
|
||||
return db.query("SELECT * FROM users");
|
||||
};
|
||||
`,
|
||||
{ parser: "ts" },
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"import db from "./db";
|
||||
|
||||
export const getData = async () => {
|
||||
return db.query("SELECT * FROM users");
|
||||
};"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should remove 'use client' directive with single quotes", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
'use client';
|
||||
import React from "react";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return <div>Hello, world!</div>;
|
||||
};
|
||||
`,
|
||||
{ parser: "tsx" },
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"import React from "react";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return <div>Hello, world!</div>;
|
||||
};"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should remove multiple directives", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
"use client";
|
||||
"use strict";
|
||||
import React from "react";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return <div>Hello, world!</div>;
|
||||
};
|
||||
`,
|
||||
{ parser: "tsx" },
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
""use strict";;
|
||||
import React from "react";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return <div>Hello, world!</div>;
|
||||
};"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should ignore directives inside functions", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
import React from "react";
|
||||
|
||||
export const MyComponent = () => {
|
||||
"use client";
|
||||
return <div>Hello, world!</div>;
|
||||
};
|
||||
`,
|
||||
{ parser: "tsx" },
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"import React from "react";
|
||||
|
||||
export const MyComponent = () => {
|
||||
"use client";
|
||||
return <div>Hello, world!</div>;
|
||||
};"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should preserve comments", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
// comment before
|
||||
"use client";
|
||||
// comment after
|
||||
import React from "react";
|
||||
`,
|
||||
{ parser: "tsx" }
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"// comment before
|
||||
// comment after
|
||||
import React from "react";"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should remove 'use-client' directive with hyphen", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
"use-client";
|
||||
import type { FC } from "react";
|
||||
// types
|
||||
import type { TDeDupeIssue } from "@plane/types";
|
||||
|
||||
type TDuplicateModalRootProps = {
|
||||
workspaceSlug: string;
|
||||
issues: TDeDupeIssue[];
|
||||
handleDuplicateIssueModal: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export function DuplicateModalRoot(props: TDuplicateModalRootProps) {
|
||||
const { workspaceSlug, issues, handleDuplicateIssueModal } = props;
|
||||
return <></>;
|
||||
}
|
||||
`,
|
||||
{ parser: "tsx" },
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"import type { FC } from "react";
|
||||
// types
|
||||
import type { TDeDupeIssue } from "@plane/types";
|
||||
|
||||
type TDuplicateModalRootProps = {
|
||||
workspaceSlug: string;
|
||||
issues: TDeDupeIssue[];
|
||||
handleDuplicateIssueModal: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export function DuplicateModalRoot(props: TDuplicateModalRootProps) {
|
||||
const { workspaceSlug, issues, handleDuplicateIssueModal } = props;
|
||||
return <></>;
|
||||
}"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should remove 'use-client' directive with hyphen and single quotes", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
'use-client';
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
// plane imports
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
|
||||
export function MyComponent() {
|
||||
return <div>Hello</div>;
|
||||
}
|
||||
`,
|
||||
{ parser: "tsx" },
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
// plane imports
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
|
||||
export function MyComponent() {
|
||||
return <div>Hello</div>;
|
||||
}"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should remove 'use-server' directive with hyphen", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
"use-server";
|
||||
import db from "./db";
|
||||
|
||||
export const getData = async () => {
|
||||
return db.query("SELECT * FROM users");
|
||||
};
|
||||
`,
|
||||
{ parser: "ts" },
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"import db from "./db";
|
||||
|
||||
export const getData = async () => {
|
||||
return db.query("SELECT * FROM users");
|
||||
};"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should handle 'use-client' in modal component structure", async () => {
|
||||
const result = await applyTransform(
|
||||
transformer,
|
||||
`
|
||||
"use-client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
// plane imports
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
// hooks
|
||||
import useKeypress from "@/hooks/use-keypress";
|
||||
// local imports
|
||||
import { InboxIssueCreateRoot } from "./create-root";
|
||||
|
||||
type TInboxIssueCreateModalRoot = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
modalState: boolean;
|
||||
handleModalClose: () => void;
|
||||
};
|
||||
|
||||
export function InboxIssueCreateModalRoot(props: TInboxIssueCreateModalRoot) {
|
||||
const { workspaceSlug, projectId, modalState, handleModalClose } = props;
|
||||
return <></>;
|
||||
}
|
||||
`,
|
||||
{ parser: "tsx" },
|
||||
);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
// plane imports
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
// hooks
|
||||
import useKeypress from "@/hooks/use-keypress";
|
||||
// local imports
|
||||
import { InboxIssueCreateRoot } from "./create-root";
|
||||
|
||||
type TInboxIssueCreateModalRoot = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
modalState: boolean;
|
||||
handleModalClose: () => void;
|
||||
};
|
||||
|
||||
export function InboxIssueCreateModalRoot(props: TInboxIssueCreateModalRoot) {
|
||||
const { workspaceSlug, projectId, modalState, handleModalClose } = props;
|
||||
return <></>;
|
||||
}"
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
13
packages/codemods/tsconfig.json
Normal file
13
packages/codemods/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
}
|
||||
}
|
||||
7
packages/codemods/vitest.config.ts
Normal file
7
packages/codemods/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user