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
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SH
The [src](./src) directory contains the main and test sources.

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

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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ Extensionless "rc" files can have JSON or YAML format.
Under the hood, `css-typed` uses [lilconfig] to load configuration files.
It supports YAML files via [js-yaml].

See [src/config.ts](src/config.ts) for the implementation.
See [config.ts](src/cli/config.ts) for the implementation.

</details>

Expand Down
1 change: 1 addition & 0 deletions fixtures/fixtures-root.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const fixturesRoot = import.meta.dirname;
76 changes: 76 additions & 0 deletions src/cli/command-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Command, Option } from "@commander-js/extra-typings";
import { glob } from "glob";

import type { Options } from "../options.ts";
import { localsConventionChoices } from "../options.ts";
import { version } from "../version.ts";
import { loadFileConfig } from "./config.ts";

// eslint-disable-next-line quotes -- Module must be quotes
declare module "@commander-js/extra-typings" {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- Extending class
interface Command {
cssTypedAction: typeof cssTypedAction;
}
}

/** Creates css-typed command with global options. */
export function createCssTypedCommand() {
const command = new Command()
.argument(`[pattern]`, `Glob path for CSS files to target.`)
.option(`-c, --config <file>`, `Custom path to the configuration file.`)
.addOption(
new Option(
`--localsConvention <value>`,
`Style of exported classnames. See https://github.com/connorjs/css-typed/tree/v${version}#localsConvention`,
)
.choices(localsConventionChoices)
.default(`dashesOnly` as const),
)
.option(
`-o, --outdir <outDirectory>`,
`Root directory for generated CSS declaration files.`,
);
command.cssTypedAction = cssTypedAction.bind(command);
return command;
}

/** Standardizes global option handling and simplifies action interface. */
function cssTypedAction(
this: Command<[string | undefined], Partial<Options> & { config?: string }>,
fileHandler: (files: string[], options: Options) => Promise<void>,
) {
return this.action(
async (cliPattern, { config: cliConfigPath, ...cliOptions }, program) => {
// Load file configuration first
const configResult = await loadFileConfig(cliConfigPath);
if (configResult?.filepath) {
// We loaded the file
console.debug(
`[debug] Reading configuration from ${configResult.filepath}.`,
);
} else if (cliConfigPath) {
// We did not load the file, but we expected to with `-c/--config`, so error
return program.error(`[error] Failed to parse ${cliConfigPath}.`);
}

// Remove pattern argument from file config, if present.
const { pattern: filePattern, ...fileConfig } =
configResult?.config ?? {};

// Resolve options from file config and CLI. CLI overrides file config.
const options: Options = { ...fileConfig, ...cliOptions };

// Pattern is required. CLI overrides file config.
const pattern = cliPattern ?? filePattern;
if (!pattern) {
// Match commander error message
return program.error(`[error] Missing required argument 'pattern'`);
}

// Find the files and delegate them to the callback
const files = await glob(pattern);
return fileHandler(files, options);
},
);
}
2 changes: 1 addition & 1 deletion src/config.ts → src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { LilconfigResult, Loaders } from "lilconfig";
import { lilconfig } from "lilconfig";
import type { OverrideProperties } from "type-fest";

import type { Options } from "./options.ts";
import type { Options } from "../options.ts";

const name = `css-typed`;
const rcAlt = `csstyped`;
Expand Down
9 changes: 5 additions & 4 deletions src/logic.test.ts → src/commands/generate-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import * as process from "node:process";

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

import { dtsPath, generateDeclaration } from "./logic.js";
import type { Options } from "./options.ts";
import { localsConventionChoices } from "./options.ts";
import { fixturesRoot } from "../../fixtures/fixtures-root.ts";
import type { Options } from "../options.ts";
import { localsConventionChoices } from "../options.ts";
import { dtsPath, generateDeclaration } from "./generate-logic.ts";

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

function fixtureFile(filename: string) {
return path.join(import.meta.dirname, `..`, `fixtures`, filename);
return path.join(fixturesRoot, filename);
}
2 changes: 1 addition & 1 deletion src/logic.ts → src/commands/generate-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import path from "node:path";
import { parse as parseCss, walk } from "css-tree";
import camelCase from "lodash.camelcase";

import type { LocalsConvention, Options } from "./options.ts";
import type { LocalsConvention, Options } from "../options.ts";

/**
* Generates TypeScript declaration file for the stylesheet file at the given
Expand Down
43 changes: 43 additions & 0 deletions src/commands/generate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { existsSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";

import type { Options } from "../options.ts";
import { dtsPath, generateDeclaration } from "./generate-logic.ts";

/**
* Generates the TypeScript declaration files and writes them to file.
*
* @param files - Paths to the original stylesheet files.
* @param options - Options object.
* @returns Empty promise indicating when writing has completed.
*/
export async function generate(files: string[], options: Options) {
const time = new Date().toISOString();
await Promise.all(
files.map((file) =>
generateDeclaration(file, time, options).then((ts) =>
writeDeclarationFile(file, options.outdir, ts),
),
),
);
}

/** Writes the TypeScript declaration content to file. Handles the output path. */
async function writeDeclarationFile(
file: string,
outdir: string | undefined,
ts: string | undefined,
) {
if (!ts) {
return undefined;
}

const [directoryToWrite, fileToWrite] = dtsPath(file, outdir);
if (!existsSync(directoryToWrite)) {
await mkdir(directoryToWrite, { recursive: true });
}

const pathToWrite = path.join(directoryToWrite, fileToWrite);
await writeFile(pathToWrite, ts, { encoding: `utf8` });
}
106 changes: 8 additions & 98 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,104 +1,14 @@
#!/usr/bin/env node

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

import { Command, Option } from "@commander-js/extra-typings";
import { glob } from "glob";

import { loadFileConfig } from "./config.ts";
import { dtsPath, generateDeclaration } from "./logic.js";
import type { Options } from "./options.ts";
import { localsConventionChoices } from "./options.ts";

declare let VERSION: string; // Defined by esbuild
const version = VERSION;

await new Command()
await createCssTypedCommand()
.name(`css-typed`)
.description(`TypeScript declaration generator for CSS files.`)
.version(version)
.argument(`[pattern]`, `Glob path for CSS files to target.`)
.option(`-c, --config <file>`, `Custom path to the configuration file.`)
.addOption(
new Option(
`--localsConvention <value>`,
`Style of exported classnames. See https://github.com/connorjs/css-typed/tree/v${version}#localsConvention`,
)
.choices(localsConventionChoices)
.default(`dashesOnly` as const),
.description(
`TypeScript declaration generator for CSS files (and other stylesheets).`,
)
.option(
`-o, --outdir <outDirectory>`,
`Root directory for generated CSS declaration files.`,
)
.action(async function (
cliPattern,
{ config: cliConfigPath, ...cliOptions },
program,
) {
// Load file configuration first
const configResult = await loadFileConfig(cliConfigPath);
if (configResult?.filepath) {
// We loaded the file
console.debug(
`[debug] Reading configuration from ${configResult.filepath}.`,
);
} else if (cliConfigPath) {
// We did not load the file, but we expected to with `-c/--config`, so error
return program.error(`[error] Failed to parse ${cliConfigPath}.`);
}

// Remove pattern argument from file config, if present.
const { pattern: filePattern, ...fileConfig } = configResult?.config ?? {};

// Resolve options from file config and CLI. CLI overrides file config.
const options: Options = { ...fileConfig, ...cliOptions };

// Pattern is required. CLI overrides file config.
const pattern = cliPattern ?? filePattern;
if (!pattern) {
// Match commander error message
return program.error(`[error] Missing required argument 'pattern'`);
}

// Find the files and process each.
const files = await glob(pattern);

const time = new Date().toISOString();
await Promise.all(
files.map((file) =>
generateDeclaration(file, time, options).then((ts) =>
writeDeclarationFile(file, options.outdir, ts),
),
),
);
})
.version(version)
.cssTypedAction(generate)
.parseAsync();

/**
* Writes the TypeScript declaration content to file. Handles the output path.
*
* @param file - Path to the original stylesheet file. NOT the path to write.
* @param outdir - Output directory to which to write.
* @param ts - The TypeScript declaration content to write.
* @returns Empty promise indicating when writing has completed.
*/
async function writeDeclarationFile(
file: string,
outdir: string | undefined,
ts: string | undefined,
) {
if (!ts) {
return undefined;
}

const [directoryToWrite, fileToWrite] = dtsPath(file, outdir);
if (!existsSync(directoryToWrite)) {
await mkdir(directoryToWrite, { recursive: true });
}

const pathToWrite = path.join(directoryToWrite, fileToWrite);
await writeFile(pathToWrite, ts, { encoding: `utf8` });
}
2 changes: 2 additions & 0 deletions src/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
declare let VERSION: string; // Defined by esbuild
export const version = VERSION;