From e186919e32320a2592301f7c72285e9482143f0f Mon Sep 17 00:00:00 2001 From: Connor Sullivan Date: Tue, 30 Jul 2024 01:13:35 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=A5=20localsConvention=20option=20(#17?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces `--dashes` option with proper `--localsConvention` option that matches PostCSS and other libraries. Changes the default to `dashesOnly` which roughly matches the previous `--dashes` implementation. BREAKING CHANGE: CLI change Adds `lodash.camelcase` to match PostCSS implementation. +semver:minor (0.x semver) Closes #2 --- .github/workflows/pipeline.yaml | 14 +-- README.md | 45 ++++--- package-lock.json | 8 +- package.json | 3 +- src/fixtures/casing/camelCase.d.css.ts | 33 +++++ src/fixtures/casing/camelCaseOnly.d.css.ts | 10 ++ src/fixtures/casing/casing.css | 12 ++ src/fixtures/casing/dashes.d.css.ts | 25 ++++ src/fixtures/casing/dashesOnly.d.css.ts | 10 ++ src/fixtures/casing/none.d.css.ts | 21 ++++ .../kebab-case/kebab-case-dashes.d.css.ts | 7 -- .../kebab-case/kebab-case-default.d.css.ts | 4 - src/fixtures/kebab-case/kebab-case.css | 22 ---- src/logic.js | 114 +++++++++++++++--- src/logic.test.js | 13 +- src/main.js | 19 ++- 16 files changed, 270 insertions(+), 90 deletions(-) create mode 100644 src/fixtures/casing/camelCase.d.css.ts create mode 100644 src/fixtures/casing/camelCaseOnly.d.css.ts create mode 100644 src/fixtures/casing/casing.css create mode 100644 src/fixtures/casing/dashes.d.css.ts create mode 100644 src/fixtures/casing/dashesOnly.d.css.ts create mode 100644 src/fixtures/casing/none.d.css.ts delete mode 100644 src/fixtures/kebab-case/kebab-case-dashes.d.css.ts delete mode 100644 src/fixtures/kebab-case/kebab-case-default.d.css.ts delete mode 100644 src/fixtures/kebab-case/kebab-case.css diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 1a1237b..2a78067 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -78,18 +78,18 @@ jobs: - name: Run css-typed (the test) # `node dist/main.js` is executing local `css-typed` as if installed (same as `bin`) # Use `-I '//.*'` to ignore the first line (comment) which has generated path and timestamp - # Test --dashes in both positions + # Test `--localsConvention` in both positions run: | - cp src/fixtures/kebab-case/kebab-case.css "$RUNNER_TEMP/kebab-case.css" + cp src/fixtures/casing/casing.css "$RUNNER_TEMP/casing.css" node dist/main.js "$RUNNER_TEMP/*.css" - diff --strip-trailing-cr -uI '//.*' src/fixtures/kebab-case/kebab-case-default.d.css.ts "$RUNNER_TEMP/kebab-case.d.css.ts" + diff --strip-trailing-cr -uI '//.*' src/fixtures/casing/dashesOnly.d.css.ts "$RUNNER_TEMP/casing.d.css.ts" - node dist/main.js "$RUNNER_TEMP/*.css" --dashes - diff --strip-trailing-cr -uI '//.*' src/fixtures/kebab-case/kebab-case-dashes.d.css.ts "$RUNNER_TEMP/kebab-case.d.css.ts" + node dist/main.js "$RUNNER_TEMP/*.css" --localsConvention camelCaseOnly + diff --strip-trailing-cr -uI '//.*' src/fixtures/casing/camelCaseOnly.d.css.ts "$RUNNER_TEMP/casing.d.css.ts" - node dist/main.js --dashes "$RUNNER_TEMP/*.css" - diff --strip-trailing-cr -uI '//.*' src/fixtures/kebab-case/kebab-case-dashes.d.css.ts "$RUNNER_TEMP/kebab-case.d.css.ts" + node dist/main.js --localsConvention camelCaseOnly "$RUNNER_TEMP/*.css" + diff --strip-trailing-cr -uI '//.*' src/fixtures/casing/camelCaseOnly.d.css.ts "$RUNNER_TEMP/casing.d.css.ts" Publish: if: ${{ github.ref == 'refs/heads/main' }} diff --git a/README.md b/README.md index 48119a1..8254d1e 100644 --- a/README.md +++ b/README.md @@ -47,35 +47,44 @@ Configure TypeScript to allow arbitrary extensions (TS 5+). ``` Add `*.d.css.ts` to your `.gitignore` if appropriate. +(See [#4] for more information about alternative output directory.) ```shell echo '*.d.css.ts' >> .gitignore ``` +[#4]: https://github.com/connorjs/css-typed/issues/4 + ## Options The following table lists the options `css-typed` supports. -Prior to the `1.0` release, these may change often. +Also run `css-typed -h` on the command line. -| CLI option | Description | -| :--------: | :---------------------------------------- | -| `--dashes` | Specifies the convention used for locals. | +| CLI option | Default | Description | +| :------------------: | :----------: | :----------------------------- | +| `--localsConvention` | `dashesOnly` | Style of exported class names. | -### Dashes +### localsConvention -_Inspired by [postcss’ localsConvention](https://github.com/madyankin/postcss-modules/tree/master#localsconvention). -Prior to `v1.0`, this option will evolve to more closely match the `localsConvention` option._ +Inspired by [postcss localsConvention](https://github.com/madyankin/postcss-modules#localsconvention). +Adds `none` option value to use the class name as-is. -The `--dashes` option changes the style of exported classnames, the exports in your TS. +The `--localsConvention` option changes the style of exported class names, the exports in your TS (i.e., the JS names). -By default, `css-typed` will emit class names as-is if the name represents a valid JS/TS identifier. -_Note: The logic for “valid” only checks hyphens (dashes, `-`) as of `v0.2.2`._ +`css-typed` will only camelize dashes in class names by default (the `dashesOnly` option value). +It will not preserve the original class name. +For example, `my-class` becomes `myClass` and you cannot use `my-class` in JS/TS code. -When passed `dashes`, it will transform `kebab-case` classes (dashed names) to `camelCase`. -For example, `my-class` becomes `myClass`. +Modern bundlers or build system such as Vite and Gatsby support this transformation. +The default matches CSS naming practices (`kebab-case`). -Use `--dashes` when your bundler or build system supports that transformation. -For example, Vite and Gatsby support this. +> **IMPORTANT** +> +> Note that the non-`*Only` values 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). ## Recipes @@ -86,8 +95,8 @@ To run it as part of your build, you will likely include it as a run script, may ```json { "scripts": { - "codegen": "css-typed 'src/**/*.css'", - "pretsc": "css-typed 'src/**/*.css'", + "codegen": "css-typed \"src/**/*.css\"", + "pretsc": "css-typed \"src/**/*.css\"", "tsc": "tsc" } } @@ -101,8 +110,8 @@ Feel free to [nodemon] or similar. ```json { "scripts": { - "codegen": "css-typed 'src/**/*.css'", - "codegen:watch": "nodemon -x 'npm run codegen' -w src -e css" + "codegen": "css-typed \"src/**/*.css\"", + "codegen:watch": "nodemon -x \"npm run codegen\" -w src -e css" } } ``` diff --git a/package-lock.json b/package-lock.json index cc360fa..12b2849 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "@commander-js/extra-typings": "^12.1.0", "commander": "^12.1.0", "css-tree": "^2.3.1", - "glob": "^11.0.0" + "glob": "^11.0.0", + "lodash.camelcase": "^4.3.0" }, "bin": { "css-typed": "dist/main.js" @@ -4438,6 +4439,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", diff --git a/package.json b/package.json index 75cfc05..88972cf 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ "@commander-js/extra-typings": "^12.1.0", "commander": "^12.1.0", "css-tree": "^2.3.1", - "glob": "^11.0.0" + "glob": "^11.0.0", + "lodash.camelcase": "^4.3.0" }, "devDependencies": { "@types/css-tree": "^2.3.8", diff --git a/src/fixtures/casing/camelCase.d.css.ts b/src/fixtures/casing/camelCase.d.css.ts new file mode 100644 index 0000000..7c003cd --- /dev/null +++ b/src/fixtures/casing/camelCase.d.css.ts @@ -0,0 +1,33 @@ +// Generated from `src/fixtures/casing/casing.css` by css-typed at $TIME + +const lowercase: string; +const UPPERCASE: string; +const uppercase: string; +const camelCase: string; +const PascalCase: string; +const pascalCase: string; +const _kebabcase: string; +const kebabCase: string; +const snake_case: string; +const snakeCase: string; +const SCREAM_CASE: string; +const screamCase: string; +const _TRAINCASE: string; +const trainCase: string; + +export = { + lowercase, + UPPERCASE, + uppercase, + camelCase, + PascalCase, + pascalCase, + "kebab-case": _kebabcase, + kebabCase, + snake_case, + snakeCase, + SCREAM_CASE, + screamCase, + "TRAIN-CASE": _TRAINCASE, + trainCase, +}; diff --git a/src/fixtures/casing/camelCaseOnly.d.css.ts b/src/fixtures/casing/camelCaseOnly.d.css.ts new file mode 100644 index 0000000..64319e4 --- /dev/null +++ b/src/fixtures/casing/camelCaseOnly.d.css.ts @@ -0,0 +1,10 @@ +// Generated from `src/fixtures/casing/casing.css` by css-typed at $TIME + +export const lowercase: string; +export const uppercase: string; +export const camelCase: string; +export const pascalCase: string; +export const kebabCase: string; +export const snakeCase: string; +export const screamCase: string; +export const trainCase: string; diff --git a/src/fixtures/casing/casing.css b/src/fixtures/casing/casing.css new file mode 100644 index 0000000..d6ccb72 --- /dev/null +++ b/src/fixtures/casing/casing.css @@ -0,0 +1,12 @@ +/* Example classes with various casing conventions. */ + +.lowercase, +.UPPERCASE, +.camelCase, +.PascalCase, +.kebab-case, +.snake_case, +.SCREAM_CASE, +.TRAIN-CASE { + display: flex; +} diff --git a/src/fixtures/casing/dashes.d.css.ts b/src/fixtures/casing/dashes.d.css.ts new file mode 100644 index 0000000..df6d7e5 --- /dev/null +++ b/src/fixtures/casing/dashes.d.css.ts @@ -0,0 +1,25 @@ +// Generated from `src/fixtures/casing/casing.css` by css-typed at $TIME + +const lowercase: string; +const UPPERCASE: string; +const camelCase: string; +const PascalCase: string; +const _kebabcase: string; +const kebabCase: string; +const snake_case: string; +const SCREAM_CASE: string; +const _TRAINCASE: string; +const TRAINCASE: string; + +export = { + lowercase, + UPPERCASE, + camelCase, + PascalCase, + "kebab-case": _kebabcase, + kebabCase, + snake_case, + SCREAM_CASE, + "TRAIN-CASE": _TRAINCASE, + TRAINCASE, +}; diff --git a/src/fixtures/casing/dashesOnly.d.css.ts b/src/fixtures/casing/dashesOnly.d.css.ts new file mode 100644 index 0000000..20ac081 --- /dev/null +++ b/src/fixtures/casing/dashesOnly.d.css.ts @@ -0,0 +1,10 @@ +// Generated from `src/fixtures/casing/casing.css` by css-typed at $TIME + +export const lowercase: string; +export const UPPERCASE: string; +export const camelCase: string; +export const PascalCase: string; +export const kebabCase: string; +export const snake_case: string; +export const SCREAM_CASE: string; +export const TRAINCASE: string; diff --git a/src/fixtures/casing/none.d.css.ts b/src/fixtures/casing/none.d.css.ts new file mode 100644 index 0000000..0575758 --- /dev/null +++ b/src/fixtures/casing/none.d.css.ts @@ -0,0 +1,21 @@ +// Generated from `src/fixtures/casing/casing.css` by css-typed at $TIME + +const lowercase: string; +const UPPERCASE: string; +const camelCase: string; +const PascalCase: string; +const _kebabcase: string; +const snake_case: string; +const SCREAM_CASE: string; +const _TRAINCASE: string; + +export = { + lowercase, + UPPERCASE, + camelCase, + PascalCase, + "kebab-case": _kebabcase, + snake_case, + SCREAM_CASE, + "TRAIN-CASE": _TRAINCASE, +}; diff --git a/src/fixtures/kebab-case/kebab-case-dashes.d.css.ts b/src/fixtures/kebab-case/kebab-case-dashes.d.css.ts deleted file mode 100644 index 3a15f60..0000000 --- a/src/fixtures/kebab-case/kebab-case-dashes.d.css.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Generated from `src/fixtures/kebab-case/kebab-case.css` by css-typed at $TIME - -export const container: string; -export const heading: string; -export const navLinks: string; -export const navLinkItem: string; -export const navLinkText: string; diff --git a/src/fixtures/kebab-case/kebab-case-default.d.css.ts b/src/fixtures/kebab-case/kebab-case-default.d.css.ts deleted file mode 100644 index 117990c..0000000 --- a/src/fixtures/kebab-case/kebab-case-default.d.css.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Generated from `src/fixtures/kebab-case/kebab-case.css` by css-typed at $TIME - -export const container: string; -export const heading: string; diff --git a/src/fixtures/kebab-case/kebab-case.css b/src/fixtures/kebab-case/kebab-case.css deleted file mode 100644 index e3145a5..0000000 --- a/src/fixtures/kebab-case/kebab-case.css +++ /dev/null @@ -1,22 +0,0 @@ -/* Originally from https://github.com/connorjs/css-typed/pull/1 */ - -.container { - margin: auto; - max-width: 500px; - font-family: sans-serif; -} - -.heading { - color: rebeccapurple; -} -.nav-links { - display: flex; - list-style: none; - padding-left: 0; -} -.nav-link-item { - padding-right: 2rem; -} -.nav-link-text { - color: black; -} diff --git a/src/logic.js b/src/logic.js index 34fc6c7..e8757d9 100644 --- a/src/logic.js +++ b/src/logic.js @@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import { parse as parseCss, walk } from "css-tree"; +import camelCase from "lodash.camelcase"; /* globals process -- Node/CLI tool */ @@ -12,7 +13,7 @@ import { parse as parseCss, walk } from "css-tree"; * * @param stylesheetPath {string} - Path to stylesheet file. * @param time {string} - Timestamp string to include in generated comment. - * @param options {{localsConvention?: "dashes"}} - Options object. + * @param options {{localsConvention: "camelCase"|"camelCaseOnly"|"dashes"|"dashesOnly"|"none"}} - Options object. * @returns {Promise} TypeScript declaration file content or * `undefined` if no declarations to write. */ @@ -22,30 +23,114 @@ export async function generateDeclaration(stylesheetPath, time, options = {}) { const css = await readFile(stylesheetPath, `utf8`); - const pathRelativeToCwd = path.relative(process.cwd(), stylesheetPath); - - let ts = `// Generated from \`${pathRelativeToCwd}\` by css-typed at ${time}\n\n`; - const ast = parseCss(css, { filename: stylesheetPath }); - const exportedNames = new Set(); + const visitedNames = new Set(); + const exportedNames = new Map(); + let hasAtLeastOneInvalidTsExportName = false; walk(ast, (node) => { if (node.type === `ClassSelector`) { // Skip duplicate names - if (exportedNames.has(node.name)) return; + if (visitedNames.has(node.name)) return; - // Skip dashed names (kebab-case), unless `localsConvention` is `dashes`. - const nameHasDashes = hasDashes(node.name); - if (nameHasDashes && options.localsConvention !== `dashes`) return; + const namesToExport = handleLocalsConvention( + node.name, + options.localsConvention, + ); - const nodeName = nameHasDashes ? dashesCamelCase(node.name) : node.name; + for (const name of namesToExport) { + // Note: This may need improvement (e.g. number at start). + const isInvalidName = hasDashes(name); + if (isInvalidName) { + hasAtLeastOneInvalidTsExportName = true; + } - ts += `export const ${nodeName}: string;\n`; - exportedNames.add(nodeName); + // Write valid TS + const validTsName = isInvalidName + ? // eslint-disable-next-line quotes -- No nested template + `_${name.replaceAll(/-+/g, "")}` + : name; + + // Save exports for final TS export. `undefined` means key is valid (used lower). + exportedNames.set(name, isInvalidName ? validTsName : undefined); + } + visitedNames.add(node.name); } }); // Only return TypeScript if we wrote something - return exportedNames.size === 0 ? undefined : ts; + if (visitedNames.size === 0) { + return undefined; + } + + return printTypeScriptDeclarationFile( + stylesheetPath, + time, + exportedNames, + hasAtLeastOneInvalidTsExportName, + ); +} + +function printTypeScriptDeclarationFile( + stylesheetPath, + time, + exportedNames, + hasAtLeastOneInvalidTsExportName, +) { + const pathRelativeToCwd = path.relative(process.cwd(), stylesheetPath); + + let ts = `// Generated from \`${pathRelativeToCwd}\` by css-typed at ${time}\n\n`; + + if (hasAtLeastOneInvalidTsExportName) { + // We have at least one invalid TS export name, so we use `export = { ... }` syntax. + // For example, we could have `export { fooBar, "foo-bar": _fooBar }`. + + // First, print all consts + for (const [name, validTsName] of exportedNames) { + ts += `const ${validTsName ?? name}: string;\n`; + } + + // Second, print export + ts += `\nexport = {\n`; + for (const [name, validTsName] of exportedNames) { + const line = validTsName ? `"${name}": ${validTsName}` : name; + ts += `\t${line},\n`; + } + ts += `};\n`; + } else { + // No invalid TS, so we use the simple `export const foo: string;` syntax + // This implies that all values are undefined, so we only use keys + for (const name of exportedNames.keys()) { + ts += `export const ${name}: string;\n`; + } + } + return ts; +} + +/** + * Handles renaming class names with `localsConvention` option. + * + * @param name {string} - Class name. + * @param localsConvention {"camelCase"|"camelCaseOnly"|"dashes"|"dashesOnly"|"none"} - Style of exported class names. + * @return {string[]} - Names to write. (Could return the original and modified name.) + */ +function handleLocalsConvention(name, localsConvention) { + switch (localsConvention) { + case `camelCase`: { + return [name, camelCase(name)]; + } + case `camelCaseOnly`: { + return [camelCase(name)]; + } + case `dashes`: { + return [name, dashesCamelCase(name)]; + } + case `dashesOnly`: { + return [dashesCamelCase(name)]; + } + default: { + return [name]; + } + } } function hasDashes(/*string*/ s) { @@ -53,6 +138,7 @@ function hasDashes(/*string*/ s) { } // Modifies postcss-modules `dashesCamelCase` function to use `replaceAll` given +// https://github.com/madyankin/postcss-modules/blob/master/src/localsConvention.js // https://github.com/sindresorhus/eslint-plugin-unicorn/blob/main/docs/rules/prefer-string-replace-all.md function dashesCamelCase(/*string*/ s) { return s.replaceAll(/-+(\w)/g, (_, firstLetter) => firstLetter.toUpperCase()); diff --git a/src/logic.test.js b/src/logic.test.js index 4943605..883e1e1 100644 --- a/src/logic.test.js +++ b/src/logic.test.js @@ -14,12 +14,13 @@ describe(`css-typed`, () => { describe.each([ [`foo.css`, `foo.d.css.ts`, {}], [`foo.module.css`, `foo.module.d.css.ts`, {}], - [`kebab-case/kebab-case.css`, `kebab-case/kebab-case-default.d.css.ts`, {}], - [ - `kebab-case/kebab-case.css`, - `kebab-case/kebab-case-dashes.d.css.ts`, - { localsConvention: `dashes` }, - ], + ...[`camelCase`, `camelCaseOnly`, `dashes`, `dashesOnly`, `none`].map( + (localsConvention) => [ + `casing/casing.css`, + `casing/${localsConvention}.d.css.ts`, + { localsConvention }, + ], + ), ])(`%s → %s`, (inputFilename, outputFilename, options) => { it(`should match expected output`, async () => { const inputPath = fixtureFile(inputFilename); diff --git a/src/main.js b/src/main.js index 3a22e35..83d82fd 100755 --- a/src/main.js +++ b/src/main.js @@ -2,7 +2,7 @@ import { writeFile } from "node:fs/promises"; -import { Command } from "@commander-js/extra-typings"; +import { Command, Option } from "@commander-js/extra-typings"; import { glob } from "glob"; import { dtsPath, generateDeclaration } from "./logic.js"; @@ -13,22 +13,21 @@ await new Command() .description(`TypeScript declaration generator for CSS files.`) .version(version) .argument(``, `Glob path for CSS files to target.`) - .option( - `--dashes`, - `Transform kebab-case classes (dashed names) to camelCase.`, - false, + .addOption( + new Option( + `--localsConvention [localsConvention]`, + `Style of exported classnames. See https://github.com/connorjs/css-typed/tree/v${version}#localsConvention`, + ) + .choices([`camelCase`, `camelCaseOnly`, `dashes`, `dashesOnly`, `none`]) + .default(`dashesOnly`), ) .action(async function (pattern, options, program) { - const declarationOptions = options.dashes - ? { localsConvention: `dashes` } - : {}; - const files = await glob(pattern); const time = new Date().toISOString(); const results = await Promise.all( files.map((file) => - generateDeclaration(file, time, declarationOptions).then((ts) => + generateDeclaration(file, time, options).then((ts) => writeDeclarationFile(file, ts), ), ),