diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index f1c78d7..9fe66a9 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -45,20 +45,19 @@ jobs: Test: name: Test (${{ matrix.node }} | ${{ matrix.platform.os }}) - needs: Build # Verify ci-build first defaults: run: shell: bash - runs-on: ${{ matrix.platform.os }} + runs-on: ${{ matrix.platform.os }}-latest strategy: matrix: node: - 20.x - 22.x platform: - - os: ubuntu-latest - - os: macos-latest - - os: windows-latest + - os: ubuntu + - os: macos + - os: windows fail-fast: false steps: @@ -77,29 +76,52 @@ jobs: - name: Build run: npm run build - - name: "Test 1: Default case" + - name: "Test 1: default case" run: | - scripts/test.sh foo + scripts/test.sh foo '' '*.css' - name: "Test 2: localsConvention, first position" if: success() || failure() run: | - scripts/test.sh casing/casing "--localsConvention camelCaseOnly" casing/camelCaseOnly + scripts/test.sh casing/casing '' '--localsConvention camelCaseOnly *.css' casing/camelCaseOnly - name: "Test 3: localsConvention, second position" if: success() || failure() run: | - scripts/test.sh casing/casing "" casing/camelCaseOnly "--localsConvention camelCaseOnly" + scripts/test.sh casing/casing '' '*.css --localsConvention camelCaseOnly' casing/camelCaseOnly - name: "Test 4: relative outdir" if: success() || failure() run: | - scripts/test.sh foo "--outdir generated" "" "" generated/ + scripts/test.sh foo '' '--outdir generated *.css' '' generated/ - name: "Test 5: absolute outdir" if: success() || failure() run: | - scripts/test.sh foo "-o $GITHUB_WORKSPACE/generated" "" "" "$GITHUB_WORKSPACE"/generated/ + scripts/test.sh foo "" "-o $GITHUB_WORKSPACE/generated *.css" "" "$GITHUB_WORKSPACE"/generated/ + # Note: This test uses double quotes, which expands differently. + + - name: "Test 6: json file config" + if: success() || failure() + run: | + scripts/test.sh foo csstypedrc.json + + - name: "Test 7: yaml file config" + if: success() || failure() + run: | + scripts/test.sh foo csstypedrc.yaml '' '' generated/ + + - name: "Test 8: custom config path" + if: success() || failure() + run: | + scripts/test.sh foo css-typed-rc.yaml '-c .config/css-typed-rc.yaml' + + - name: "Test 9: mjs file config" + if: matrix.platform.os != 'windows' && (success() || failure()) + # Do not run on Windows due to Windows-only ESM import bug. + # This _could_ be an issue with css-typed, but could be a test/deps issue. + run: | + scripts/test.sh foo custom-config-path.config.mjs '-c .config/custom-config-path.config.mjs' Publish: if: ${{ github.ref == 'refs/heads/main' }} diff --git a/README.md b/README.md index ba3704d..51e7c2b 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ TypeScript declaration generator for CSS files. Table of Contents - [Usage](#usage) +- [Options](#options) - [Recipes](#recipes) - [Motivation](#motivation) - [Contributing](#contributing) @@ -69,9 +70,35 @@ echo '*.d.css.ts' >> .gitignore The following table lists the options `css-typed` supports. Also run `css-typed -h` on the command line. -| CLI option | Default | Description | -| :------------------: | :----------: | :----------------------------- | -| `--localsConvention` | `dashesOnly` | Style of exported class names. | +| CLI option | Default | Description | +| :------------------: | :----------: | :------------------------------------- | +| `-c` or `--config` | Heuristics | Custom path to the configuration file. | +| `--localsConvention` | `dashesOnly` | Style of exported class names. | + +### config + +`css-typed` supports loading options from a configuration file instead of using command line arguments. +To load from a custom path, use the `-c` or `--config` option. +By default, `css-typed` looks in the following locations. +Extensionless "rc" files can have JSON or YAML format. + +- Package file: `css-typed` property in `package.json` or `package.yaml` +- Root rc files: `.csstypedrc` with no extension or one of `json`, `yaml`, `yml`, `js`, `cjs`, or `mjs` +- Config folder rc files: `.config/csstypedrc` with no extension or one of `json`, `yaml`, `yml`, `js`, `cjs`, or `mjs` +- Root config files: `css-typed.config` with an extension of `js`, `cjs`, or `mjs` + +
+Look under the hood + +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. + +
+ +[lilconfig]: https://www.npmjs.com/package/lilconfig +[js-yaml]: https://www.npmjs.com/package/js-yaml ### localsConvention @@ -89,11 +116,12 @@ The default matches CSS naming practices (`kebab-case`). > **IMPORTANT** > -> Note that the non-`*Only` values MAY have TypeScript bugs. +> Note that `camelCase` and `dashes` MAY have TypeScript bugs. > TypeScript 5.6 may help with the named exports for these. > > If you encounter a bug, please file an issue. -> In the mean-time, consider using `camelCaseOnly` instead (or `dashesOnly` which is the default). +> In the mean-time, consider using `camelCaseOnly` instead. +> (Or `dashesOnly` which is the default.) ## Recipes @@ -145,6 +173,7 @@ declare module "*.module.css" { Both depend on [css-modules-loader-core], which appears [abandoned][174]. Therefore, I wrote my own (very basic) implementation. +See [§Implementation details](#implementation-details) for more information. [typescript-plugin-css-modules]: https://www.npmjs.com/package/typescript-plugin-css-modules [typed-css-modules]: https://www.npmjs.com/package/typed-css-modules @@ -161,7 +190,17 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md). This (very basic) implementation uses [glob] for file matching and [css-tree] for CSS parsing. It extracts CSS classes (`ClassSelector` in CSS Tree’s AST) and exports them as `string` constants (named exports). +The CSS-file class name is modified for JS export according to the [localsConvention](#localsconvention) option. +The implementation matches PostCSS. + I chose CSS Tree after a brief search because it had a nice API, good documentation, and supported CSS nesting (a requirement for my original use case). +`css-typed` uses [Commander.js][commander] for command line parsing and [lilconfig] for configuration file loading. + +The “brand” image/logo combines the public CSS 3 and TypeScript logos with a basic plus icon in between. +See [css-typed.svg](images/css-typed.svg). + [glob]: https://www.npmjs.com/package/glob [css-tree]: https://www.npmjs.com/package/css-tree +[commander]: https://www.npmjs.com/package/commander +[lilconfig]: https://www.npmjs.com/package/lilconfig diff --git a/fixtures/config/css-typed-rc.yaml b/fixtures/config/css-typed-rc.yaml new file mode 100644 index 0000000..689bd3b --- /dev/null +++ b/fixtures/config/css-typed-rc.yaml @@ -0,0 +1 @@ +pattern: "*.css" diff --git a/fixtures/config/csstypedrc.json b/fixtures/config/csstypedrc.json new file mode 100644 index 0000000..59606de --- /dev/null +++ b/fixtures/config/csstypedrc.json @@ -0,0 +1 @@ +{ "pattern": "*.css" } diff --git a/fixtures/config/csstypedrc.yaml b/fixtures/config/csstypedrc.yaml new file mode 100644 index 0000000..dd33ed0 --- /dev/null +++ b/fixtures/config/csstypedrc.yaml @@ -0,0 +1,2 @@ +pattern: "*.css" +outdir: generated diff --git a/fixtures/config/custom-config-path.config.mjs b/fixtures/config/custom-config-path.config.mjs new file mode 100644 index 0000000..6627fb2 --- /dev/null +++ b/fixtures/config/custom-config-path.config.mjs @@ -0,0 +1 @@ +export default { pattern: `*.css` }; diff --git a/package-lock.json b/package-lock.json index 24fb7e6..430fb60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,8 @@ "commander": "^12.1.0", "css-tree": "^2.3.1", "glob": "^11.0.0", + "js-yaml": "^4.1.0", + "lilconfig": "^3.1.2", "lodash.camelcase": "^4.3.0" }, "bin": { @@ -21,6 +23,7 @@ "devDependencies": { "@connorjs/tsconfig": "~0.3.0", "@types/css-tree": "^2.3.8", + "@types/js-yaml": "^4.0.9", "@types/lodash.camelcase": "^4.3.9", "@types/node": "^20.14.14", "esbuild": "~0.23.0", @@ -30,6 +33,7 @@ "lint-staged": "^15.2.8", "npm-run-all": "^4.1.5", "prettier": "^3.3.3", + "type-fest": "^4.23.0", "typescript": "^5.5.4", "vitest": "^2.0.5" }, @@ -1053,6 +1057,12 @@ "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "dev": true }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1480,8 +1490,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/aria-query": { "version": "5.1.3", @@ -3436,6 +3445,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globals/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -4195,7 +4216,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -4334,7 +4354,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==", - "dev": true, "engines": { "node": ">=14" }, @@ -6557,12 +6576,12 @@ } }, "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.23.0.tgz", + "integrity": "sha512-ZiBujro2ohr5+Z/hZWHESLz3g08BBdrdLMieYFULJO+tWc437sn8kQsWLJoZErY8alNhxre9K4p3GURAG11n+w==", "dev": true, "engines": { - "node": ">=10" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" diff --git a/package.json b/package.json index 0722196..7213582 100644 --- a/package.json +++ b/package.json @@ -50,11 +50,14 @@ "commander": "^12.1.0", "css-tree": "^2.3.1", "glob": "^11.0.0", + "js-yaml": "^4.1.0", + "lilconfig": "^3.1.2", "lodash.camelcase": "^4.3.0" }, "devDependencies": { "@connorjs/tsconfig": "~0.3.0", "@types/css-tree": "^2.3.8", + "@types/js-yaml": "^4.0.9", "@types/lodash.camelcase": "^4.3.9", "@types/node": "^20.14.14", "esbuild": "~0.23.0", @@ -64,6 +67,7 @@ "lint-staged": "^15.2.8", "npm-run-all": "^4.1.5", "prettier": "^3.3.3", + "type-fest": "^4.23.0", "typescript": "^5.5.4", "vitest": "^2.0.5" } diff --git a/scripts/build.js b/scripts/build.js index 71ae0a0..a1ee3e4 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -24,4 +24,4 @@ const result = await build({ }); const analysis = await analyzeMetafile(result.metafile); -console.log(analysis); +console.info(analysis); diff --git a/scripts/test.sh b/scripts/test.sh index 8d7e62e..4f2f526 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -1,16 +1,18 @@ #!/usr/bin/env bash +set -eo pipefail # Removed `-u` which failed on macos for `options` +IFS=$' ' # We want space splitting for this script # $1 is the input name, relative to `fixtures`. Required. input=$1 -# $2 is the standard (before) options. Defaults to "". -IFS=" " read -r -a beforeOpts <<< "${2:-}" +# $2 is the config file name, relative to `fixtures/config`. Defaults to $1.yaml. +config=${2:-$1.yaml} -# $3 is the output name, relative to `fixtures`. Defaults to $1. -output=${3:-$1} +# $3 is the options. Defaults to "". +read -r -a options <<< "${3:-}" -# $4 is the after options. Use an array. Defaults to "". -IFS=" " read -r -a afterOpts <<< "${4:-}" +# $4 is the output name, relative to `fixtures`. Defaults to $1. +output=${4:-$1} # $5 is the path prefix for output. Defaults to "". prefix=${5:-} @@ -18,14 +20,21 @@ prefix=${5:-} # Run from $RUNNER_TEMP for auto-cleanup. cp fixtures/${input}.css $RUNNER_TEMP/test.css cp fixtures/${output}.d.css.ts $RUNNER_TEMP/expected.d.css.ts + +rm -rf "${RUNNER_TEMP:?}/.config" +if [ -f fixtures/config/${config} ]; then + mkdir -p $RUNNER_TEMP/.config + cp fixtures/config/${config} $RUNNER_TEMP/.config/${config} +fi + pushd $RUNNER_TEMP > /dev/null || exit # `./dist/main.js` is executing local `css-typed` as if installed (same as `bin`). # But it is `$GITHUB_WORKSPACE/dist/main.js` b/c we `cd $RUNNER_TEMP`. -echo "css-typed ${beforeOpts[*]} \"*.css\" ${afterOpts[*]}" +echo "css-typed " "${options[@]}" # shellcheck disable=SC2068 -$GITHUB_WORKSPACE/dist/main.js ${beforeOpts[@]} "*.css" ${afterOpts[@]} +$GITHUB_WORKSPACE/dist/main.js ${options[@]} # Use `diff` to compare the files. # Use `-I '//.*'` to ignore the first line (comment) which has generated path and timestamp. -diff --color=auto --strip-trailing-cr -uI "//.*" expected.d.css.ts ${prefix}test.d.css.ts +diff --color=always --strip-trailing-cr -uI "//.*" expected.d.css.ts ${prefix}test.d.css.ts diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..e59cc28 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,78 @@ +import { load } from "js-yaml"; +import type { LilconfigResult, Loaders } from "lilconfig"; +import { lilconfig } from "lilconfig"; +import type { OverrideProperties } from "type-fest"; + +import type { Options } from "./options.ts"; + +const name = `css-typed`; +const rcAlt = `csstyped`; + +/** + * Places to search for the config. + * + * The history of the values follow. + * + * 1. Start with [lilconfig default array][a] allowing mjs (async). + * + * 2. Add extensionless rc at the root (matches cosmiconfig). + * + * 3. Change to use two naming conventions. + * 1. package prop and config files use `css-typed` (with hyphen, match the module name). + * 2. RC files use `csstyped` (no hyphen) because `.css-typedrc` feels wrong compared to `.csstypedrc`. + * This matches the convention `lint-staged` uses: `lint-staged.config.js` and `.lintstagedrc`. + * + * 4. Add YAML files, preferring `.yaml` over `.yml` (following the [YAML FAQ][b] guidance and matching cosmiconfig). + * css-typed supports parsing YAML via the `js-yaml` package. + * Check YAML after JSON values (matches cosmiconfig). + * Include `package.yaml`. + * + * [a]: https://github.com/antonk52/lilconfig/blob/2cb3e756e1e1d890caee88d3f44a898c7903b2a2/src/index.js#L10-L24 + * [b]: https://yaml.org/faq.html + */ +// Default lilconfig search places, modified for no hyphen in rc case +const searchPlaces = [ + `package.json`, + `package.yaml`, + `.${rcAlt}rc`, + `.${rcAlt}rc.json`, + `.${rcAlt}rc.yaml`, + `.${rcAlt}rc.yml`, + `.${rcAlt}rc.js`, + `.${rcAlt}rc.cjs`, + `.${rcAlt}rc.mjs`, + `.config/${rcAlt}rc`, + `.config/${rcAlt}rc.json`, + `.config/${rcAlt}rc.yaml`, + `.config/${rcAlt}rc.yml`, + `.config/${rcAlt}rc.js`, + `.config/${rcAlt}rc.cjs`, + `.config/${rcAlt}rc.mjs`, + `${name}.config.js`, + `${name}.config.cjs`, + `${name}.config.mjs`, +]; + +const loaders: Loaders = { + ".yaml": loadYaml, + ".yml": loadYaml, + noExt: loadYaml, +}; + +const configSearcher = lilconfig(name, { loaders, searchPlaces }); + +/** Loads the css-typed configuration file. */ +export function loadFileConfig(configPath: string | undefined) { + return ( + configPath ? configSearcher.load(configPath) : configSearcher.search() + ) as Promise; +} + +type CssTypedConfig = OverrideProperties< + NonNullable, + { config: { pattern?: string } & Options } +>; + +function loadYaml(filename: string, content: string) { + return load(content, { filename }); +} diff --git a/src/main.ts b/src/main.ts index 1aff532..81b7069 100755 --- a/src/main.ts +++ b/src/main.ts @@ -7,7 +7,9 @@ import path from "node:path"; 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 @@ -17,10 +19,11 @@ await new Command() .name(`css-typed`) .description(`TypeScript declaration generator for CSS files.`) .version(version) - .argument(``, `Glob path for CSS files to target.`) + .argument(`[pattern]`, `Glob path for CSS files to target.`) + .option(`-c, --config `, `Custom path to the configuration file.`) .addOption( new Option( - `--localsConvention `, + `--localsConvention `, `Style of exported classnames. See https://github.com/connorjs/css-typed/tree/v${version}#localsConvention`, ) .choices(localsConventionChoices) @@ -30,7 +33,37 @@ await new Command() `-o, --outdir `, `Root directory for generated CSS declaration files.`, ) - .action(async function (pattern, options) { + .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(); diff --git a/src/options.ts b/src/options.ts index a15e075..d191ff5 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,5 +1,6 @@ export type Options = { localsConvention?: LocalsConvention; + outdir?: string; }; export type LocalsConvention = (typeof localsConventionChoices)[number];