Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/better-kings-fail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"flowbite-react": patch
---

fix(wrapDefaultExport): handle complex multiline exports
86 changes: 86 additions & 0 deletions packages/ui/src/cli/utils/wrap-default-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,90 @@ export const a = 1;
module.exports = wrapper(myFunction);`;
expect(wrapDefaultExport(input, "wrapper").trim()).toBe(expected.trim());
});

it("handles complex multiline exports with nested function calls and objects", () => {
const input = `import { withHOC } from "./utils";
import type { Config } from "./types";

const config: Config = {
/* config options here */
};

export default withHOC(config, {
feature1: true,
feature2: "enabled",
callbacks: {
onInit: () => console.log("initialized"),
onUpdate: (data) => console.log("updated:", data)
},
options: [
{ id: 1, value: "first" },
{ id: 2, value: "second" }
]
});`;

const expected = `import { withHOC } from "./utils";
import type { Config } from "./types";

const config: Config = {
/* config options here */
};

export default wrapper(withHOC(config, {
feature1: true,
feature2: "enabled",
callbacks: {
onInit: () => console.log("initialized"),
onUpdate: (data) => console.log("updated:", data)
},
options: [
{ id: 1, value: "first" },
{ id: 2, value: "second" }
]
}));`;

expect(wrapDefaultExport(input, "wrapper")).toBe(expected);
});

it("handles complex multiline CJS exports with nested function calls and objects", () => {
const input = `const { withHOC } = require("./utils");

const config = {
/* config options here */
};

module.exports = withHOC(config, {
feature1: true,
feature2: "enabled",
callbacks: {
onInit: () => console.log("initialized"),
onUpdate: (data) => console.log("updated:", data)
},
options: [
{ id: 1, value: "first" },
{ id: 2, value: "second" }
]
});`;

const expected = `const { withHOC } = require("./utils");

const config = {
/* config options here */
};

module.exports = wrapper(withHOC(config, {
feature1: true,
feature2: "enabled",
callbacks: {
onInit: () => console.log("initialized"),
onUpdate: (data) => console.log("updated:", data)
},
options: [
{ id: 1, value: "first" },
{ id: 2, value: "second" }
]
}));`;

expect(wrapDefaultExport(input, "wrapper")).toBe(expected);
});
});
88 changes: 65 additions & 23 deletions packages/ui/src/cli/utils/wrap-default-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,33 +16,75 @@ export function wrapDefaultExport(content: string, withFunction: string): string

let wrappedContent = content;

// Handle ESM exports
if (isESM) {
const esmMatch = content.match(/export\s+default\s+(?:class|interface|abstract\s+class)\s+/);
if (!esmMatch) {
wrappedContent = wrappedContent.replace(
/(export\s+default\s+)([^;\n]+(?:{[^}]*})?[^;\n]*)(;?\s*)$/gm,
(_, prefix, exportValue, semicolon) => {
const trimmedValue = exportValue.trim();
return `${prefix}${withFunction}(${trimmedValue})${semicolon}`;
},
);
const config = isCJS ? EXPORT_CONFIGS.cjs : EXPORT_CONFIGS.esm;

// Skip if it's a class/interface export
if (!content.match(config.skipPattern)) {
const lastExportMatch = wrappedContent.match(config.matchPattern);
if (lastExportMatch) {
const [fullMatch, prefix, rest] = lastExportMatch;
const { exportValue, restContent } = extractExportValue(rest);
const replacement = createWrappedReplacement(prefix, exportValue, withFunction, restContent);

// Replace only the last occurrence
const index = wrappedContent.lastIndexOf(fullMatch);
wrappedContent = wrappedContent.slice(0, index) + replacement + wrappedContent.slice(index + fullMatch.length);
}
}
Comment on lines +21 to 33
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

"Last occurrence" logic doesn’t actually target the last export

match(config.matchPattern) captures from the first occurrence to EOF; replacing the “last occurrence” of fullMatch still uses the first prefix. This fails when multiple module.exports = (or rare multiple ESM defaults in generated code) appear; it won’t wrap the last one.

Apply this diff to locate the last prefix and compute rest from that position:

-  // Skip if it's a class/interface export
-  if (!content.match(config.skipPattern)) {
-    const lastExportMatch = wrappedContent.match(config.matchPattern);
-    if (lastExportMatch) {
-      const [fullMatch, prefix, rest] = lastExportMatch;
-      const { exportValue, restContent } = extractExportValue(rest);
-      const replacement = createWrappedReplacement(prefix, exportValue, withFunction, restContent);
-
-      // Replace only the last occurrence
-      const index = wrappedContent.lastIndexOf(fullMatch);
-      wrappedContent = wrappedContent.slice(0, index) + replacement + wrappedContent.slice(index + fullMatch.length);
-    }
-  }
+  // Skip if it's a class/interface export
+  if (!content.match(config.skipPattern)) {
+    const match = wrappedContent.match(config.matchPattern);
+    if (match) {
+      const [, prefix] = match;
+      const index = wrappedContent.lastIndexOf(prefix);
+      if (index !== -1) {
+        const restFromLast = wrappedContent.slice(index + prefix.length);
+        const { exportValue, restContent } = extractExportValue(restFromLast);
+        const replacement = createWrappedReplacement(prefix, exportValue, withFunction, restContent);
+        wrappedContent = wrappedContent.slice(0, index) + replacement;
+      }
+    }
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Skip if it's a class/interface export
if (!content.match(config.skipPattern)) {
const lastExportMatch = wrappedContent.match(config.matchPattern);
if (lastExportMatch) {
const [fullMatch, prefix, rest] = lastExportMatch;
const { exportValue, restContent } = extractExportValue(rest);
const replacement = createWrappedReplacement(prefix, exportValue, withFunction, restContent);
// Replace only the last occurrence
const index = wrappedContent.lastIndexOf(fullMatch);
wrappedContent = wrappedContent.slice(0, index) + replacement + wrappedContent.slice(index + fullMatch.length);
}
}
// Skip if it's a class/interface export
if (!content.match(config.skipPattern)) {
const match = wrappedContent.match(config.matchPattern);
if (match) {
const [, prefix] = match;
const index = wrappedContent.lastIndexOf(prefix);
if (index !== -1) {
const restFromLast = wrappedContent.slice(index + prefix.length);
const { exportValue, restContent } = extractExportValue(restFromLast);
const replacement = createWrappedReplacement(prefix, exportValue, withFunction, restContent);
wrappedContent = wrappedContent.slice(0, index) + replacement;
}
}
}


// Handle CJS exports
if (isCJS) {
const cjsMatch = content.match(/module\.exports\s*=\s*(?:class|interface|abstract\s+class)\s+/);
if (!cjsMatch) {
wrappedContent = wrappedContent.replace(
/(module\.exports\s*=\s*)([^;\n]+(?:{[^}]*})?[^;\n]*)(;?\s*)$/gm,
(_, prefix, exportValue, semicolon) => {
const trimmedValue = exportValue.trim();
return `${prefix}${withFunction}(${trimmedValue})${semicolon}`;
},
);
return wrappedContent;
}

const EXPORT_CONFIGS = {
esm: {
skipPattern: /export\s+default\s+(?:class|interface|abstract\s+class)\s+/,
matchPattern: /(export\s+default\s+)([\s\S]*$)/m,
},
cjs: {
skipPattern: /module\.exports\s*=\s*(?:class|interface|abstract\s+class)\s+/,
matchPattern: /(module\.exports\s*=\s*)([\s\S]*$)/m,
},
};

/**
* Extracts the export value from a string, handling nested structures
*/
function extractExportValue(rest: string): { exportValue: string; restContent: string } {
let depth = 0;
let i = 0;

// Parse the export value handling nested parentheses and braces
for (i = 0; i < rest.length; i++) {
const char = rest[i];
if (char === "(" || char === "{") depth++;
if (char === ")" || char === "}") depth--;

// Break on semicolon or newline if we're not inside parentheses/braces
if (depth === 0 && (char === ";" || char === "\n")) {
return {
exportValue: rest.slice(0, char === ";" ? i + 1 : i),
restContent: rest.slice(char === ";" ? i + 1 : i),
};
}
}

return wrappedContent;
// If we didn't find a terminator, use the whole rest
return {
exportValue: rest,
restContent: "",
};
}
Comment on lines +52 to +76
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Parser misses arrays and breaks when a newline follows export default

  • Arrays ([]) aren’t tracked in depth, so multiline array exports split early on the first newline.
  • If there’s a newline immediately after export default (common in formatted code), exportValue becomes empty.

Apply this diff to track brackets and skip leading whitespace/newlines:

-function extractExportValue(rest: string): { exportValue: string; restContent: string } {
-  let depth = 0;
-  let i = 0;
-
-  // Parse the export value handling nested parentheses and braces
-  for (i = 0; i < rest.length; i++) {
-    const char = rest[i];
-    if (char === "(" || char === "{") depth++;
-    if (char === ")" || char === "}") depth--;
-
-    // Break on semicolon or newline if we're not inside parentheses/braces
-    if (depth === 0 && (char === ";" || char === "\n")) {
-      return {
-        exportValue: rest.slice(0, char === ";" ? i + 1 : i),
-        restContent: rest.slice(char === ";" ? i + 1 : i),
-      };
-    }
-  }
-
-  // If we didn't find a terminator, use the whole rest
-  return {
-    exportValue: rest,
-    restContent: "",
-  };
-}
+function extractExportValue(rest: string): { exportValue: string; restContent: string } {
+  let depth = 0;
+  let i = 0;
+
+  // Skip leading whitespace/newlines between the prefix and the expression
+  while (i < rest.length && /\s/.test(rest[i])) i++;
+  const start = i;
+
+  // Parse the export value handling nested (), {}, []
+  for (; i < rest.length; i++) {
+    const char = rest[i];
+    if (char === "(" || char === "{" || char === "[") depth++;
+    else if (char === ")" || char === "}" || char === "]") depth = Math.max(0, depth - 1);
+
+    // Break on semicolon or newline if we're not inside nested structures
+    if (depth === 0 && (char === ";" || char === "\n")) {
+      return {
+        exportValue: rest.slice(start, char === ";" ? i + 1 : i),
+        restContent: rest.slice(char === ";" ? i + 1 : i),
+      };
+    }
+  }
+
+  // If we didn't find a terminator, use the remaining substring
+  return {
+    exportValue: rest.slice(start),
+    restContent: "",
+  };
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function extractExportValue(rest: string): { exportValue: string; restContent: string } {
let depth = 0;
let i = 0;
// Parse the export value handling nested parentheses and braces
for (i = 0; i < rest.length; i++) {
const char = rest[i];
if (char === "(" || char === "{") depth++;
if (char === ")" || char === "}") depth--;
// Break on semicolon or newline if we're not inside parentheses/braces
if (depth === 0 && (char === ";" || char === "\n")) {
return {
exportValue: rest.slice(0, char === ";" ? i + 1 : i),
restContent: rest.slice(char === ";" ? i + 1 : i),
};
}
}
return wrappedContent;
// If we didn't find a terminator, use the whole rest
return {
exportValue: rest,
restContent: "",
};
}
function extractExportValue(rest: string): { exportValue: string; restContent: string } {
let depth = 0;
let i = 0;
// Skip leading whitespace/newlines between the prefix and the expression
while (i < rest.length && /\s/.test(rest[i])) i++;
const start = i;
// Parse the export value handling nested (), {}, []
for (; i < rest.length; i++) {
const char = rest[i];
if (char === "(" || char === "{" || char === "[") depth++;
else if (char === ")" || char === "}" || char === "]") depth = Math.max(0, depth - 1);
// Break on semicolon or newline if we're not inside nested structures
if (depth === 0 && (char === ";" || char === "\n")) {
return {
exportValue: rest.slice(start, char === ";" ? i + 1 : i),
restContent: rest.slice(char === ";" ? i + 1 : i),
};
}
}
// If we didn't find a terminator, use the remaining substring
return {
exportValue: rest.slice(start),
restContent: "",
};
}
🤖 Prompt for AI Agents
In packages/ui/src/cli/utils/wrap-default-export.ts around lines 52 to 76, the
parser doesn't track square brackets and treats an immediate newline after
"export default" as a terminator, producing an empty exportValue; update the
function to skip leading whitespace/newlines before parsing and include "[" and
"]" in the depth tracking logic (increment depth for "(", "{", "[" and decrement
for ")", "}", "]") so multiline arrays and formatted exports are handled
correctly, and only treat semicolon/newline as terminators when depth is zero
after skipping leading whitespace.


/**
* Creates a wrapped replacement for an export statement
*/
function createWrappedReplacement(
prefix: string,
exportValue: string,
withFunction: string,
restContent: string,
): string {
const trimmedValue = exportValue.trim();
const hasTrailingSemi = trimmedValue.endsWith(";");
return `${prefix}${withFunction}(${trimmedValue.replace(/;$/, "")})${hasTrailingSemi ? ";" : ""}${restContent}`;
}
Loading