diff --git a/packages/core/src/postcss/ast-generating.test.ts b/packages/core/src/postcss/ast-generating.test.ts index b29697a..efb1631 100644 --- a/packages/core/src/postcss/ast-generating.test.ts +++ b/packages/core/src/postcss/ast-generating.test.ts @@ -27,6 +27,40 @@ export default config; ); }); +test("parseAndUpdatePostcssConfig is idempotent for array plugin configs", () => { + const source = `module.exports = { plugins: ["@react-zero-ui/core/postcss", "@tailwindcss/postcss"] };`; + const updated = parseAndUpdatePostcssConfig(source, zeroUiPlugin, false); + + assert.equal(updated, source); + assert.equal((updated?.split(zeroUiPlugin).length ?? 1) - 1, 1); +}); + +test("parseAndUpdatePostcssConfig reorders array plugin configs so Zero-UI comes before Tailwind", () => { + const source = `module.exports = { plugins: ["@tailwindcss/postcss", "@react-zero-ui/core/postcss"] };`; + const updated = parseAndUpdatePostcssConfig(source, zeroUiPlugin, false); + + assert.ok(updated); + assert.equal((updated?.split(zeroUiPlugin).length ?? 1) - 1, 1); + assert.ok(updated!.indexOf(zeroUiPlugin) < updated!.indexOf("@tailwindcss/postcss")); +}); + +test("parseAndUpdatePostcssConfig is idempotent for object plugin configs", () => { + const source = `module.exports = { plugins: { "@react-zero-ui/core/postcss": {}, "@tailwindcss/postcss": {} } };`; + const updated = parseAndUpdatePostcssConfig(source, zeroUiPlugin, false); + + assert.equal(updated, source); + assert.equal((updated?.split(zeroUiPlugin).length ?? 1) - 1, 1); +}); + +test("parseAndUpdatePostcssConfig reorders object plugin configs so Zero-UI comes before Tailwind", () => { + const source = `module.exports = { plugins: { "@tailwindcss/postcss": {}, "@react-zero-ui/core/postcss": {} } };`; + const updated = parseAndUpdatePostcssConfig(source, zeroUiPlugin, false); + + assert.ok(updated); + assert.equal((updated?.split(zeroUiPlugin).length ?? 1) - 1, 1); + assert.ok(updated!.indexOf(zeroUiPlugin) < updated!.indexOf("@tailwindcss/postcss")); +}); + const FIXTURES = { // 1. ESM object "vite.config.js": ` diff --git a/packages/core/src/postcss/ast-generating.ts b/packages/core/src/postcss/ast-generating.ts index 3df6840..60762e9 100644 --- a/packages/core/src/postcss/ast-generating.ts +++ b/packages/core/src/postcss/ast-generating.ts @@ -35,6 +35,7 @@ export function parseAndUpdatePostcssConfig(source: string, zeroUiPlugin: string const ast = parse(source, AST_CONFIG_OPTS); let modified = false; + let handled = false; traverse(ast, { // Handle CommonJS: module.exports = { ... } and exports = { ... } @@ -52,7 +53,9 @@ export function parseAndUpdatePostcssConfig(source: string, zeroUiPlugin: string ); if (pluginsProperty && t.isExpression(pluginsProperty.value)) { - modified = addZeroUiToPlugins(pluginsProperty.value, zeroUiPlugin); + const result = addZeroUiToPlugins(pluginsProperty.value, zeroUiPlugin); + handled ||= result !== null; + modified ||= result === true; } } }, @@ -65,7 +68,9 @@ export function parseAndUpdatePostcssConfig(source: string, zeroUiPlugin: string ); if (pluginsProperty && t.isExpression(pluginsProperty.value)) { - modified = addZeroUiToPlugins(pluginsProperty.value, zeroUiPlugin); + const result = addZeroUiToPlugins(pluginsProperty.value, zeroUiPlugin); + handled ||= result !== null; + modified ||= result === true; } } }, @@ -78,7 +83,9 @@ export function parseAndUpdatePostcssConfig(source: string, zeroUiPlugin: string ); if (pluginsProperty && t.isExpression(pluginsProperty.value)) { - modified = addZeroUiToPlugins(pluginsProperty.value, zeroUiPlugin); + const result = addZeroUiToPlugins(pluginsProperty.value, zeroUiPlugin); + handled ||= result !== null; + modified ||= result === true; } } }, @@ -86,6 +93,8 @@ export function parseAndUpdatePostcssConfig(source: string, zeroUiPlugin: string if (modified) { return generate(ast).code; + } else if (handled) { + return source; } else { console.warn(`[Zero-UI] Failed to automatically modify PostCSS config: ${source}`); return null; // Could not automatically modify @@ -101,19 +110,68 @@ export function parseAndUpdatePostcssConfig(source: string, zeroUiPlugin: string * Helper function to add Zero-UI plugin to plugins configuration * Handles both object format {plugin: {}} and array format [plugin] */ -function addZeroUiToPlugins(pluginsNode: t.Expression, zeroUiPlugin: string): boolean { +function addZeroUiToPlugins(pluginsNode: t.Expression, zeroUiPlugin: string): boolean | null { if (t.isObjectExpression(pluginsNode)) { - // Object format: { 'plugin': {} } - pluginsNode.properties.unshift(t.objectProperty(t.stringLiteral(zeroUiPlugin), t.objectExpression([]))); - return true; + return normalizePluginEntries( + pluginsNode.properties, + (prop): prop is t.ObjectProperty => t.isObjectProperty(prop) && getPluginObjectKey(prop) === zeroUiPlugin, + (prop): prop is t.ObjectProperty => t.isObjectProperty(prop) && getPluginObjectKey(prop) === "@tailwindcss/postcss", + () => t.objectProperty(t.stringLiteral(zeroUiPlugin), t.objectExpression([])) + ); } else if (t.isArrayExpression(pluginsNode)) { - // Array format: ['plugin'] - pluginsNode.elements.unshift(t.stringLiteral(zeroUiPlugin)); - return true; + return normalizePluginEntries( + pluginsNode.elements, + (el) => isPluginArrayEntry(el, zeroUiPlugin), + (el) => isPluginArrayEntry(el, "@tailwindcss/postcss"), + () => t.stringLiteral(zeroUiPlugin) + ); + } + return null; +} + +function normalizePluginEntries( + items: T[], + isZeroUi: (item: T) => boolean, + isTailwind: (item: T) => boolean, + createZeroUi: () => T +): boolean { + const zeroEntries = items.filter(isZeroUi); + const zeroIndex = items.findIndex(isZeroUi); + const tailwindIndex = items.findIndex(isTailwind); + + // Already correct: exactly one Zero-UI entry and it already comes before Tailwind. + if (zeroEntries.length === 1 && (tailwindIndex === -1 || zeroIndex < tailwindIndex)) { + return false; + } + + const normalized = items.filter((item) => !isZeroUi(item)); + const insertIndex = normalized.findIndex(isTailwind); + + normalized.splice(insertIndex === -1 ? 0 : insertIndex, 0, zeroEntries[0] ?? createZeroUi()); + items.splice(0, items.length, ...normalized); + return true; +} + +function isPluginArrayEntry(node: t.ArrayExpression["elements"][number], pluginName: string): boolean { + if (!node) return false; + if (t.isStringLiteral(node)) return node.value === pluginName; + if ( + t.isCallExpression(node) && + t.isIdentifier(node.callee, { name: "require" }) && + node.arguments.length === 1 && + t.isStringLiteral(node.arguments[0]) + ) { + return node.arguments[0].value === pluginName; } return false; } +function getPluginObjectKey(node: t.ObjectProperty): string | null { + if (t.isStringLiteral(node.key)) return node.key.value; + if (t.isIdentifier(node.key)) return node.key.name; + return null; +} + /** * Parse Vite config TypeScript/JavaScript file and add Zero-UI plugin * Removes tailwindcss plugin if present, and adds zeroUI plugin if missing diff --git a/packages/core/src/postcss/helpers.test.ts b/packages/core/src/postcss/helpers.test.ts index 0aeb11f..a39038d 100644 --- a/packages/core/src/postcss/helpers.test.ts +++ b/packages/core/src/postcss/helpers.test.ts @@ -215,8 +215,8 @@ test("patchPostcssConfig inserts Zero-UI before Tailwind in existing config", as test("patchPostcssConfig is idempotent when Zero-UI plugin already present", async () => { const original = `module.exports = { plugins: { - ${ZERO}: {}, - ${TAIL}: {}, + "${ZERO}": {}, + "${TAIL}": {}, }, };`; @@ -226,6 +226,38 @@ test("patchPostcssConfig is idempotent when Zero-UI plugin already present", asy }); }); +test("patchPostcssConfig does not duplicate Zero-UI in array plugin configs", async () => { + const original = `module.exports = { + plugins: ["${ZERO}", "${TAIL}"], +};`; + + await runTest({ "postcss.config.js": original }, async () => { + await patchPostcssConfig(); + + const updated = readFile("postcss.config.js"); + const zeroCount = updated.split(ZERO).length - 1; + + assert.equal(zeroCount, 1, "Zero-UI plugin must not be duplicated"); + assert.ok(zeroBeforeTail(updated), "Zero-UI must precede Tailwind"); + }); +}); + +test("patchPostcssConfig reorders array plugin configs when Tailwind comes first", async () => { + const original = `module.exports = { + plugins: ["${TAIL}", "${ZERO}"], +};`; + + await runTest({ "postcss.config.js": original }, async () => { + await patchPostcssConfig(); + + const updated = readFile("postcss.config.js"); + const zeroCount = updated.split(ZERO).length - 1; + + assert.equal(zeroCount, 1, "Zero-UI plugin must not be duplicated"); + assert.ok(zeroBeforeTail(updated), "Zero-UI must precede Tailwind"); + }); +}); + test("patchViteConfig creates defineConfig setup when no plugin array exists", async () => { await runTest( {