Skip to content

Commit aa5ac9d

Browse files
committed
feat: add --preserve-paths option to CLI for maintaining folder hierarchy
#377
1 parent 3f39411 commit aa5ac9d

File tree

7 files changed

+180
-6
lines changed

7 files changed

+180
-6
lines changed

docs/CLI.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ Options:
5555
-s, --safelist <list...> list of classes that should not be removed
5656
-b, --blocklist <list...> list of selectors that should be removed
5757
-k, --skippedContentGlobs <list...> list of glob patterns for folders/files that should not be scanned
58+
-p, --preserve-paths preserve folder hierarchy in the output
5859
-h, --help display help for command
5960
```
6061

@@ -90,6 +91,24 @@ By default, the CLI outputs the result in the console. If you wish to return the
9091
purgecss --css css/app.css --content src/index.html "src/**/*.js" --output build/css/
9192
```
9293

94+
### --preserve-paths
95+
96+
By default, the CLI flattens the folder hierarchy and outputs all CSS files to the same directory. If you want to preserve the original folder structure in the output, use the `--preserve-paths` flag.
97+
98+
```sh
99+
purgecss --css src/**/*.css --content src/index.html --output build/ --preserve-paths
100+
```
101+
102+
For example, if your CSS files are located at:
103+
- `src/styles/main.css`
104+
- `src/components/button.css`
105+
106+
Without `--preserve-paths`, both files would be written to `build/main.css` and `build/button.css`.
107+
108+
With `--preserve-paths`, the files would be written to:
109+
- `build/src/styles/main.css`
110+
- `build/src/components/button.css`
111+
93112
### --safelist
94113

95114
If you wish to prevent PurgeCSS from removing a specific CSS selector, you can add it to the safelist.
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { Command } from "commander";
2+
import { promises as asyncFs } from "fs";
3+
import * as path from "path";
4+
import { parseCommandOptions, run } from "../../src/bin";
5+
6+
const CLI_TEST_FOLDER = path.resolve(__dirname, "../test_examples/cli/nested/");
7+
8+
describe("PurgeCSS CLI preserve paths", () => {
9+
const tempFolder = path.resolve(CLI_TEST_FOLDER, ".temp");
10+
11+
beforeAll(async () => {
12+
try {
13+
await asyncFs.access(tempFolder);
14+
// Clean up temp folder if it exists
15+
await asyncFs.rm(tempFolder, { recursive: true });
16+
} catch {
17+
// Folder doesn't exist, that's fine
18+
}
19+
await asyncFs.mkdir(tempFolder, { recursive: true });
20+
});
21+
22+
afterAll(async () => {
23+
try {
24+
await asyncFs.rm(tempFolder, { recursive: true });
25+
} catch {
26+
// Ignore cleanup errors
27+
}
28+
});
29+
30+
it("should flatten folder hierarchy by default", async () => {
31+
const program = parseCommandOptions(new Command());
32+
program.parse([
33+
"purgecss",
34+
"",
35+
"--content",
36+
`${CLI_TEST_FOLDER}/src/content.html`,
37+
"--css",
38+
`${CLI_TEST_FOLDER}/src/root.css`,
39+
`${CLI_TEST_FOLDER}/src/level1/middle.css`,
40+
`${CLI_TEST_FOLDER}/src/level1/level2/nested.css`,
41+
"--output",
42+
`${tempFolder}/flattened`,
43+
]);
44+
await run(program);
45+
46+
// All files should be in the same directory (flattened)
47+
const rootCss = (
48+
await asyncFs.readFile(`${tempFolder}/flattened/root.css`)
49+
).toString();
50+
const middleCss = (
51+
await asyncFs.readFile(`${tempFolder}/flattened/middle.css`)
52+
).toString();
53+
const nestedCss = (
54+
await asyncFs.readFile(`${tempFolder}/flattened/nested.css`)
55+
).toString();
56+
57+
expect(rootCss).toContain(".root-class");
58+
expect(rootCss).not.toContain(".unused-root");
59+
expect(middleCss).toContain(".middle-class");
60+
expect(middleCss).not.toContain(".unused-middle");
61+
expect(nestedCss).toContain(".nested-class");
62+
expect(nestedCss).not.toContain(".unused-nested");
63+
});
64+
65+
it("should preserve folder hierarchy when --preserve-paths is used", async () => {
66+
const program = parseCommandOptions(new Command());
67+
program.parse([
68+
"purgecss",
69+
"",
70+
"--content",
71+
`${CLI_TEST_FOLDER}/src/content.html`,
72+
"--css",
73+
`${CLI_TEST_FOLDER}/src/root.css`,
74+
`${CLI_TEST_FOLDER}/src/level1/middle.css`,
75+
`${CLI_TEST_FOLDER}/src/level1/level2/nested.css`,
76+
"--output",
77+
`${tempFolder}/preserved`,
78+
"--preserve-paths",
79+
]);
80+
await run(program);
81+
82+
// Files should preserve their original path structure
83+
const rootCss = (
84+
await asyncFs.readFile(
85+
`${tempFolder}/preserved/${CLI_TEST_FOLDER}/src/root.css`,
86+
)
87+
).toString();
88+
const middleCss = (
89+
await asyncFs.readFile(
90+
`${tempFolder}/preserved/${CLI_TEST_FOLDER}/src/level1/middle.css`,
91+
)
92+
).toString();
93+
const nestedCss = (
94+
await asyncFs.readFile(
95+
`${tempFolder}/preserved/${CLI_TEST_FOLDER}/src/level1/level2/nested.css`,
96+
)
97+
).toString();
98+
99+
expect(rootCss).toContain(".root-class");
100+
expect(rootCss).not.toContain(".unused-root");
101+
expect(middleCss).toContain(".middle-class");
102+
expect(middleCss).not.toContain(".unused-middle");
103+
expect(nestedCss).toContain(".nested-class");
104+
expect(nestedCss).not.toContain(".unused-nested");
105+
});
106+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<title>Nested Test</title>
5+
</head>
6+
<body>
7+
<div class="nested-class">Nested</div>
8+
<div class="middle-class">Middle</div>
9+
<div class="root-class">Root</div>
10+
</body>
11+
</html>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.nested-class {
2+
color: blue;
3+
}
4+
5+
.unused-nested {
6+
background: red;
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.middle-class {
2+
color: green;
3+
}
4+
5+
.unused-middle {
6+
background: yellow;
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.root-class {
2+
color: purple;
3+
}
4+
5+
.unused-root {
6+
background: orange;
7+
}

packages/purgecss/src/bin.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ type CommandOptions = {
3838
safelist?: string[];
3939
blocklist?: string[];
4040
skippedContentGlobs: string[];
41+
preservePaths?: boolean;
4142
};
4243

4344
export function parseCommandOptions(program: Command): Command {
@@ -70,12 +71,15 @@ export function parseCommandOptions(program: Command): Command {
7071
.option(
7172
"-k, --skippedContentGlobs <list...>",
7273
"list of glob patterns for folders/files that should not be scanned",
73-
);
74+
)
75+
.option("-p, --preserve-paths", "preserve folder hierarchy in the output");
7476

7577
return program;
7678
}
7779

78-
export async function getOptions(program: Command): Promise<Options> {
80+
export async function getOptions(
81+
program: Command,
82+
): Promise<Options & { preservePaths?: boolean }> {
7983
const {
8084
config,
8185
css,
@@ -89,6 +93,7 @@ export async function getOptions(program: Command): Promise<Options> {
8993
safelist,
9094
blocklist,
9195
skippedContentGlobs,
96+
preservePaths,
9297
} = program.opts<CommandOptions>();
9398
// config file is not specified or the content and css are not,
9499
// PurgeCSS will not run
@@ -134,12 +139,13 @@ export async function getOptions(program: Command): Promise<Options> {
134139
if (blocklist) options.blocklist = blocklist;
135140
if (skippedContentGlobs) options.skippedContentGlobs = skippedContentGlobs;
136141
if (output) options.output = output;
137-
return options;
142+
return { ...options, preservePaths };
138143
}
139144

140145
export async function run(program: Command) {
141146
const options = await getOptions(program);
142-
const purged = await new PurgeCSS().purge(options);
147+
const { preservePaths, ...purgeOptions } = options;
148+
const purged = await new PurgeCSS().purge(purgeOptions);
143149

144150
// output results in specified directory
145151
if (options.output) {
@@ -149,8 +155,19 @@ export async function run(program: Command) {
149155
}
150156

151157
for (const purgedResult of purged) {
152-
const fileName = purgedResult?.file?.split("/").pop();
153-
await writeCSSToFile(`${options.output}/${fileName}`, purgedResult.css);
158+
let outputPath: string;
159+
if (preservePaths && purgedResult?.file) {
160+
// Preserve the folder hierarchy
161+
outputPath = `${options.output}/${purgedResult.file}`;
162+
} else {
163+
// Default behavior: flatten to just the filename
164+
const fileName = purgedResult?.file?.split("/").pop();
165+
outputPath = `${options.output}/${fileName}`;
166+
}
167+
// Ensure the directory exists
168+
const dir = outputPath.substring(0, outputPath.lastIndexOf("/"));
169+
await fs.promises.mkdir(dir, { recursive: true });
170+
await writeCSSToFile(outputPath, purgedResult.css);
154171
}
155172
} else {
156173
console.log(JSON.stringify(purged));

0 commit comments

Comments
 (0)