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];