Skip to content

Commit bf68221

Browse files
authored
♻️ Refactor before adding subcommands (#29)
Refactors the existing implementation in advance of adding additional subcommands. dist/main.js: 3.3kb (+3%)
1 parent 4eae464 commit bf68221

10 files changed

+140
-106
lines changed

CONTRIBUTING.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SH
2121
The [src](./src) directory contains the main and test sources.
2222

2323
- [main.ts](./src/main.ts) represents the entry point (the CLI tool).
24-
- [logic.ts](./src/logic.ts) represents the unit-tested logic.
24+
- [cli](./src/cli) contains CLI-related code that is not unit tested.
25+
- [commands](src/commands) represents the unit-tested commands and logic.
2526

2627
The [fixtures](fixtures) directory contains files for data-file-driven unit tests.
2728

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ Extensionless "rc" files can have JSON or YAML format.
9393
Under the hood, `css-typed` uses [lilconfig] to load configuration files.
9494
It supports YAML files via [js-yaml].
9595

96-
See [src/config.ts](src/config.ts) for the implementation.
96+
See [config.ts](src/cli/config.ts) for the implementation.
9797

9898
</details>
9999

fixtures/fixtures-root.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const fixturesRoot = import.meta.dirname;

src/cli/command-utils.ts

+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { Command, Option } from "@commander-js/extra-typings";
2+
import { glob } from "glob";
3+
4+
import type { Options } from "../options.ts";
5+
import { localsConventionChoices } from "../options.ts";
6+
import { version } from "../version.ts";
7+
import { loadFileConfig } from "./config.ts";
8+
9+
// eslint-disable-next-line quotes -- Module must be quotes
10+
declare module "@commander-js/extra-typings" {
11+
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- Extending class
12+
interface Command {
13+
cssTypedAction: typeof cssTypedAction;
14+
}
15+
}
16+
17+
/** Creates css-typed command with global options. */
18+
export function createCssTypedCommand() {
19+
const command = new Command()
20+
.argument(`[pattern]`, `Glob path for CSS files to target.`)
21+
.option(`-c, --config <file>`, `Custom path to the configuration file.`)
22+
.addOption(
23+
new Option(
24+
`--localsConvention <value>`,
25+
`Style of exported classnames. See https://github.com/connorjs/css-typed/tree/v${version}#localsConvention`,
26+
)
27+
.choices(localsConventionChoices)
28+
.default(`dashesOnly` as const),
29+
)
30+
.option(
31+
`-o, --outdir <outDirectory>`,
32+
`Root directory for generated CSS declaration files.`,
33+
);
34+
command.cssTypedAction = cssTypedAction.bind(command);
35+
return command;
36+
}
37+
38+
/** Standardizes global option handling and simplifies action interface. */
39+
function cssTypedAction(
40+
this: Command<[string | undefined], Partial<Options> & { config?: string }>,
41+
fileHandler: (files: string[], options: Options) => Promise<void>,
42+
) {
43+
return this.action(
44+
async (cliPattern, { config: cliConfigPath, ...cliOptions }, program) => {
45+
// Load file configuration first
46+
const configResult = await loadFileConfig(cliConfigPath);
47+
if (configResult?.filepath) {
48+
// We loaded the file
49+
console.debug(
50+
`[debug] Reading configuration from ${configResult.filepath}.`,
51+
);
52+
} else if (cliConfigPath) {
53+
// We did not load the file, but we expected to with `-c/--config`, so error
54+
return program.error(`[error] Failed to parse ${cliConfigPath}.`);
55+
}
56+
57+
// Remove pattern argument from file config, if present.
58+
const { pattern: filePattern, ...fileConfig } =
59+
configResult?.config ?? {};
60+
61+
// Resolve options from file config and CLI. CLI overrides file config.
62+
const options: Options = { ...fileConfig, ...cliOptions };
63+
64+
// Pattern is required. CLI overrides file config.
65+
const pattern = cliPattern ?? filePattern;
66+
if (!pattern) {
67+
// Match commander error message
68+
return program.error(`[error] Missing required argument 'pattern'`);
69+
}
70+
71+
// Find the files and delegate them to the callback
72+
const files = await glob(pattern);
73+
return fileHandler(files, options);
74+
},
75+
);
76+
}

src/config.ts src/cli/config.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { LilconfigResult, Loaders } from "lilconfig";
33
import { lilconfig } from "lilconfig";
44
import type { OverrideProperties } from "type-fest";
55

6-
import type { Options } from "./options.ts";
6+
import type { Options } from "../options.ts";
77

88
const name = `css-typed`;
99
const rcAlt = `csstyped`;

src/logic.test.ts src/commands/generate-logic.test.ts

+5-4
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import * as process from "node:process";
33

44
import { describe, expect, it } from "vitest";
55

6-
import { dtsPath, generateDeclaration } from "./logic.js";
7-
import type { Options } from "./options.ts";
8-
import { localsConventionChoices } from "./options.ts";
6+
import { fixturesRoot } from "../../fixtures/fixtures-root.ts";
7+
import type { Options } from "../options.ts";
8+
import { localsConventionChoices } from "../options.ts";
9+
import { dtsPath, generateDeclaration } from "./generate-logic.ts";
910

1011
describe(`css-typed`, () => {
1112
it(`should not generate an empty declaration file [#9]`, async () => {
@@ -54,5 +55,5 @@ describe(`css-typed`, () => {
5455
});
5556

5657
function fixtureFile(filename: string) {
57-
return path.join(import.meta.dirname, `..`, `fixtures`, filename);
58+
return path.join(fixturesRoot, filename);
5859
}

src/logic.ts src/commands/generate-logic.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import path from "node:path";
55
import { parse as parseCss, walk } from "css-tree";
66
import camelCase from "lodash.camelcase";
77

8-
import type { LocalsConvention, Options } from "./options.ts";
8+
import type { LocalsConvention, Options } from "../options.ts";
99

1010
/**
1111
* Generates TypeScript declaration file for the stylesheet file at the given

src/commands/generate.ts

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { existsSync } from "node:fs";
2+
import { mkdir, writeFile } from "node:fs/promises";
3+
import path from "node:path";
4+
5+
import type { Options } from "../options.ts";
6+
import { dtsPath, generateDeclaration } from "./generate-logic.ts";
7+
8+
/**
9+
* Generates the TypeScript declaration files and writes them to file.
10+
*
11+
* @param files - Paths to the original stylesheet files.
12+
* @param options - Options object.
13+
* @returns Empty promise indicating when writing has completed.
14+
*/
15+
export async function generate(files: string[], options: Options) {
16+
const time = new Date().toISOString();
17+
await Promise.all(
18+
files.map((file) =>
19+
generateDeclaration(file, time, options).then((ts) =>
20+
writeDeclarationFile(file, options.outdir, ts),
21+
),
22+
),
23+
);
24+
}
25+
26+
/** Writes the TypeScript declaration content to file. Handles the output path. */
27+
async function writeDeclarationFile(
28+
file: string,
29+
outdir: string | undefined,
30+
ts: string | undefined,
31+
) {
32+
if (!ts) {
33+
return undefined;
34+
}
35+
36+
const [directoryToWrite, fileToWrite] = dtsPath(file, outdir);
37+
if (!existsSync(directoryToWrite)) {
38+
await mkdir(directoryToWrite, { recursive: true });
39+
}
40+
41+
const pathToWrite = path.join(directoryToWrite, fileToWrite);
42+
await writeFile(pathToWrite, ts, { encoding: `utf8` });
43+
}

src/main.ts

+8-98
Original file line numberDiff line numberDiff line change
@@ -1,104 +1,14 @@
11
#!/usr/bin/env node
22

3-
import { existsSync } from "node:fs";
4-
import { mkdir, writeFile } from "node:fs/promises";
5-
import path from "node:path";
3+
import { createCssTypedCommand } from "./cli/command-utils.ts";
4+
import { generate } from "./commands/generate.ts";
5+
import { version } from "./version.ts";
66

7-
import { Command, Option } from "@commander-js/extra-typings";
8-
import { glob } from "glob";
9-
10-
import { loadFileConfig } from "./config.ts";
11-
import { dtsPath, generateDeclaration } from "./logic.js";
12-
import type { Options } from "./options.ts";
13-
import { localsConventionChoices } from "./options.ts";
14-
15-
declare let VERSION: string; // Defined by esbuild
16-
const version = VERSION;
17-
18-
await new Command()
7+
await createCssTypedCommand()
198
.name(`css-typed`)
20-
.description(`TypeScript declaration generator for CSS files.`)
21-
.version(version)
22-
.argument(`[pattern]`, `Glob path for CSS files to target.`)
23-
.option(`-c, --config <file>`, `Custom path to the configuration file.`)
24-
.addOption(
25-
new Option(
26-
`--localsConvention <value>`,
27-
`Style of exported classnames. See https://github.com/connorjs/css-typed/tree/v${version}#localsConvention`,
28-
)
29-
.choices(localsConventionChoices)
30-
.default(`dashesOnly` as const),
9+
.description(
10+
`TypeScript declaration generator for CSS files (and other stylesheets).`,
3111
)
32-
.option(
33-
`-o, --outdir <outDirectory>`,
34-
`Root directory for generated CSS declaration files.`,
35-
)
36-
.action(async function (
37-
cliPattern,
38-
{ config: cliConfigPath, ...cliOptions },
39-
program,
40-
) {
41-
// Load file configuration first
42-
const configResult = await loadFileConfig(cliConfigPath);
43-
if (configResult?.filepath) {
44-
// We loaded the file
45-
console.debug(
46-
`[debug] Reading configuration from ${configResult.filepath}.`,
47-
);
48-
} else if (cliConfigPath) {
49-
// We did not load the file, but we expected to with `-c/--config`, so error
50-
return program.error(`[error] Failed to parse ${cliConfigPath}.`);
51-
}
52-
53-
// Remove pattern argument from file config, if present.
54-
const { pattern: filePattern, ...fileConfig } = configResult?.config ?? {};
55-
56-
// Resolve options from file config and CLI. CLI overrides file config.
57-
const options: Options = { ...fileConfig, ...cliOptions };
58-
59-
// Pattern is required. CLI overrides file config.
60-
const pattern = cliPattern ?? filePattern;
61-
if (!pattern) {
62-
// Match commander error message
63-
return program.error(`[error] Missing required argument 'pattern'`);
64-
}
65-
66-
// Find the files and process each.
67-
const files = await glob(pattern);
68-
69-
const time = new Date().toISOString();
70-
await Promise.all(
71-
files.map((file) =>
72-
generateDeclaration(file, time, options).then((ts) =>
73-
writeDeclarationFile(file, options.outdir, ts),
74-
),
75-
),
76-
);
77-
})
12+
.version(version)
13+
.cssTypedAction(generate)
7814
.parseAsync();
79-
80-
/**
81-
* Writes the TypeScript declaration content to file. Handles the output path.
82-
*
83-
* @param file - Path to the original stylesheet file. NOT the path to write.
84-
* @param outdir - Output directory to which to write.
85-
* @param ts - The TypeScript declaration content to write.
86-
* @returns Empty promise indicating when writing has completed.
87-
*/
88-
async function writeDeclarationFile(
89-
file: string,
90-
outdir: string | undefined,
91-
ts: string | undefined,
92-
) {
93-
if (!ts) {
94-
return undefined;
95-
}
96-
97-
const [directoryToWrite, fileToWrite] = dtsPath(file, outdir);
98-
if (!existsSync(directoryToWrite)) {
99-
await mkdir(directoryToWrite, { recursive: true });
100-
}
101-
102-
const pathToWrite = path.join(directoryToWrite, fileToWrite);
103-
await writeFile(pathToWrite, ts, { encoding: `utf8` });
104-
}

src/version.ts

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
declare let VERSION: string; // Defined by esbuild
2+
export const version = VERSION;

0 commit comments

Comments
 (0)