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
34 changes: 34 additions & 0 deletions packages/core/src/postcss/ast-generating.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": `
Expand Down
78 changes: 68 additions & 10 deletions packages/core/src/postcss/ast-generating.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = { ... }
Expand All @@ -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;
}
}
},
Expand All @@ -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;
}
}
},
Expand All @@ -78,14 +83,18 @@ 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;
}
}
},
});

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
Expand All @@ -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<T>(
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
Expand Down
36 changes: 34 additions & 2 deletions packages/core/src/postcss/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}": {},
},
};`;

Expand All @@ -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(
{
Expand Down
Loading