├── .github ├── ISSUE_TEMPLATE.md └── workflows │ ├── ci.yml │ ├── publish.yml │ └── tag.yml ├── .gitignore ├── .prettierignore ├── .travis.yml ├── LICENSE ├── README.md ├── __tests__ ├── aliases.less ├── aliases.less.d.ts ├── cli.test.ts ├── complex.less ├── complex.less.d.ts ├── core │ ├── alerts.test.ts │ ├── generate.test.ts │ ├── list-different.test.ts │ └── write-file.test.ts ├── dashes.less ├── dashes.less.d.ts ├── empty.less ├── invalid.less ├── invalid.less.d.ts ├── less │ └── file-to-class-names.test.ts ├── main.test.ts ├── nested-styles │ ├── style.less │ └── style.less.d.ts ├── style.less ├── style.less.d.ts └── typescript │ ├── class-names-to-type-definitions.test.ts │ └── get-type-definition-path.test.ts ├── codecov.yml ├── commitlint.config.js ├── docs ├── typed-less-modules-example.gif └── typed-less-modules-example.png ├── examples ├── basic │ ├── README.md │ ├── core │ │ └── variables.less │ ├── feature-a │ │ ├── index.ts │ │ ├── style.less │ │ └── style.less.d.ts │ └── feature-b │ │ ├── index.ts │ │ ├── style.less │ │ └── style.less.d.ts └── default-export │ ├── README.md │ └── feature-a │ ├── index.ts │ ├── style.less │ └── style.less.d.ts ├── jest.config.js ├── less.d.ts ├── lib ├── cli.ts ├── core │ ├── alerts.ts │ ├── generate.ts │ ├── index.ts │ ├── list-different.ts │ ├── types.ts │ ├── watch.ts │ └── write-file.ts ├── less │ ├── aliases-plugin.ts │ ├── file-to-class-names.ts │ ├── index.ts │ └── source-to-class-names.ts ├── main.ts └── typescript │ ├── class-names-to-type-definition.ts │ ├── get-type-definition-path.ts │ └── index.ts ├── package.json ├── tsconfig.json └── yarn.lock /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Your issue may already be reported! 2 | Please search on the [issue tracker](../) before creating one. 3 | 4 | ## Expected Behavior 5 | 6 | 7 | 8 | 9 | ## Current Behavior 10 | 11 | 12 | 13 | 14 | ## Possible Solution 15 | 16 | 17 | 18 | 19 | ## Steps to Reproduce (for bugs) 20 | 21 | 22 | 23 | 24 | 1. 2. 3. 4. 25 | 26 | ## Context 27 | 28 | 29 | 30 | 31 | ## Your Environment 32 | 33 | 34 | 35 | - Version used: 36 | - Operating System and versions: 37 | - Link to your project: 38 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [8.x, 10.x, 12.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v1 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - name: install deps & check 27 | run: | 28 | yarn 29 | yarn run ci 30 | env: 31 | CI: true 32 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v1 14 | - uses: actions/setup-node@v1 15 | with: 16 | node-version: 12 17 | registry-url: https://registry.npmjs.org/ 18 | - name: Install dependencies 19 | run: yarn 20 | - name: Publish to npm 21 | run: npm publish --access=public 22 | env: 23 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 24 | -------------------------------------------------------------------------------- /.github/workflows/tag.yml: -------------------------------------------------------------------------------- 1 | name: Tag 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Create tag 16 | run: | 17 | tagName="v$(jq '.version' package.json | sed "s/\"//g")" 18 | if [[ -n $(git ls-remote --tags origin $tagName) ]]; then 19 | echo "tag $tagName already exists, skip release" 20 | else 21 | git config --local user.email "actions@github.com" 22 | git config --local user.name "GitHub Actions" 23 | git tag -a $tagName -m "release by Github Actions" 24 | fi 25 | - name: Push to repo 26 | uses: ad-m/github-push-action@master 27 | with: 28 | github_token: ${{ secrets.GITHUB_TOKEN }} 29 | branch: master 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | *.rej 4 | dist 5 | 6 | # Test Relative Folders 7 | __mocks__ 8 | __coverage__ 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | __coverage__ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | if: tag IS blank 2 | 3 | language: node_js 4 | node_js: 5 | - "10" 6 | 7 | jobs: 8 | include: 9 | - stage: test 10 | name: "Unit tests, type checking, linting, etc." 11 | script: 12 | - yarn check-types 13 | - yarn test 14 | - yarn codecov 15 | - git reset --hard HEAD 16 | - yarn check-formatting 17 | - yarn build 18 | - yarn commitlint-travis 19 | # TODO: !!! @nighca 20 | # - stage: release 21 | # name: "Release on npm" 22 | # deploy: 23 | # provider: script 24 | # skip_cleanup: true 25 | # script: 26 | # - yarn semantic-release 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2020 Qiniu Cloud (qiniu.com) 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🎁 typed-less-modules 2 | 3 | [![Travis Build Status](https://img.shields.io/travis/com/qiniu/typed-less-modules/master?style=for-the-badge)](https://travis-ci.com/qiniu/typed-less-modules) 4 | [![Codecov](https://img.shields.io/codecov/c/github/qiniu/typed-less-modules?style=for-the-badge)](https://codecov.io/gh/qiniu/typed-less-modules) 5 | [![npm](https://img.shields.io/npm/v/@qiniu/typed-less-modules?color=%23c7343a&label=npm&style=for-the-badge)](https://www.npmjs.com/package/@qiniu/typed-less-modules) 6 | [![GitHub stars](https://img.shields.io/github/stars/qiniu/typed-less-modules.svg?style=for-the-badge)](https://github.com/qiniu/typed-less-modules/stargazers) 7 | [![license](https://img.shields.io/github/license/qiniu/typed-less-modules?style=for-the-badge)](https://github.com/qiniu/typed-less-modules/blob/master/LICENSE) 8 | 9 | Generate TypeScript definitions (`.d.ts`) files for CSS Modules that are written in LESS (`.less`). 10 | 11 | **typed-less-modules** 用于将 `.less` 转换为对应的 `.d.ts` TypeScript 类型声明文件。 12 | 13 | ![Example](/docs/typed-less-modules-example.gif) 14 | 15 | For example, given the following LESS: 16 | 17 | ```less 18 | @import "variables"; 19 | 20 | .text { 21 | color: @blue; 22 | 23 | &-highlighted { 24 | color: @yellow; 25 | } 26 | } 27 | ``` 28 | 29 | The following type definitions will be generated: 30 | 31 | ```typescript 32 | export const text: string; 33 | export const textHighlighted: string; 34 | ``` 35 | 36 | ## Basic Usage 37 | 38 | Run with npm package runner: 39 | 40 | ```bash 41 | npx tlm src 42 | ``` 43 | 44 | Or, install globally: 45 | 46 | ```bash 47 | yarn global add typed-less-modules 48 | tlm src 49 | ``` 50 | 51 | Or, install and run as a `devDependency`: 52 | 53 | ```bash 54 | yarn add -D typed-less-modules 55 | yarn tlm src 56 | ``` 57 | 58 | ## Advanced Usage 59 | 60 | For all possible commands, run `tlm --help`. 61 | 62 | The only required argument is the directory where all LESS files are located (`config.pattern`). Running `tlm src` will search for all files matching `src/**/*.less`. This can be overridden by providing a [glob](https://github.com/isaacs/node-glob#glob-primer) pattern instead of a directory. For example, `tlm src/*.less` 63 | 64 | ### `--watch` (`-w`) 65 | 66 | - **Type**: `boolean` 67 | - **Default**: `false` 68 | - **Example**: `tlm src --watch` 69 | 70 | Watch for files that get added or are changed and generate the corresponding type definitions. 71 | 72 | ### `--ignoreInitial` 73 | 74 | - **Type**: `boolean` 75 | - **Default**: `false` 76 | - **Example**: `tlm src --watch --ignoreInitial` 77 | 78 | Skips the initial build when passing the watch flag. Use this when running concurrently with another watch, but the initial build should happen first. You would run without watch first, then start off the concurrent runs after. 79 | 80 | ### `--ignore` 81 | 82 | - **Type**: `string[]` 83 | - **Default**: `[]` 84 | - **Example**: `tlm src --watch --ignore "**/secret.less"` 85 | 86 | A pattern or an array of glob patterns to exclude files that match and avoid generating type definitions. 87 | 88 | ### `--includePaths` (`-i`) 89 | 90 | - **Type**: `string[]` 91 | - **Default**: `[]` 92 | - **Example**: `tlm src --includePaths src/core` 93 | 94 | An array of paths to look in to attempt to resolve your `@import` declarations. This example will search the `src/core` directory when resolving imports. 95 | 96 | ### `--aliases` (`-a`) 97 | 98 | - **Type**: `object` 99 | - **Default**: `{}` 100 | - **Example**: `tlm src --aliases.~some-alias src/core/variables` 101 | 102 | An object of aliases to map to their corresponding paths. This example will replace any `@import '~alias'` with `@import 'src/core/variables'`. 103 | 104 | ### `--nameFormat` (`-n`) 105 | 106 | - **Type**: `"camel" | "kebab" | "param" | "dashes" | "none"` 107 | - **Default**: `"camel"` 108 | - **Example**: `tlm src --nameFormat camel` 109 | 110 | The class naming format to use when converting the classes to type definitions. 111 | 112 | - **camel**: convert all class names to camel-case, e.g. `App-Logo` => `appLogo`. 113 | - **kebab**/**param**: convert all class names to kebab/param case, e.g. `App-Logo` => `app-logo` (all lower case with '-' separators). 114 | - **dashes**: only convert class names containing dashes to camel-case, leave others alone, e.g. `App` => `App`, `App-Logo` => `appLogo`. Matches the webpack [css-loader camelCase 'dashesOnly'](https://github.com/webpack-contrib/css-loader#camelcase) option. 115 | - **none**: do not modify the given class names (you should use `--exportType default` when using `--nameFormat none` as any classes with a `-` in them are invalid as normal variable names). 116 | Note: If you are using create-react-app v2.x and have NOT ejected, `--nameFormat none --exportType default` matches the class names that are generated in CRA's webpack's config. 117 | 118 | ### `--listDifferent` (`-l`) 119 | 120 | - **Type**: `boolean` 121 | - **Default**: `false` 122 | - **Example**: `tlm src --listDifferent` 123 | 124 | List any type definition files that are different than those that would be generated. If any are different, exit with a status code `1`. 125 | 126 | ### `--exportType` (`-e`) 127 | 128 | - **Type**: `"named" | "default"` 129 | - **Default**: `"named"` 130 | - **Example**: `tlm src --exportType default` 131 | 132 | The export type to use when generating type definitions. 133 | 134 | #### `named` 135 | 136 | Given the following LESS: 137 | 138 | ```less 139 | .text { 140 | color: blue; 141 | 142 | &-highlighted { 143 | color: yellow; 144 | } 145 | } 146 | ``` 147 | 148 | The following type definitions will be generated: 149 | 150 | ```typescript 151 | export const text: string; 152 | export const textHighlighted: string; 153 | ``` 154 | 155 | #### `default` 156 | 157 | Given the following LESS: 158 | 159 | ```less 160 | .text { 161 | color: blue; 162 | 163 | &-highlighted { 164 | color: yellow; 165 | } 166 | } 167 | ``` 168 | 169 | The following type definitions will be generated: 170 | 171 | ```typescript 172 | export type Styles = { 173 | text: string; 174 | textHighlighted: string; 175 | }; 176 | 177 | export type ClassNames = keyof Styles; 178 | 179 | declare const styles: Styles; 180 | 181 | export default styles; 182 | ``` 183 | 184 | This export type is useful when using kebab (param) cased class names since variables with a `-` are not valid variables and will produce invalid types or when a class name is a TypeScript keyword (eg: `while` or `delete`). Additionally, the `Styles` and `ClassNames` types are exported which can be useful for properly typing variables, functions, etc. when working with dynamic class names. 185 | 186 | ### `--exportTypeName` 187 | 188 | - **Type**: `string` 189 | - **Default**: `"ClassNames"` 190 | - **Example**: `tlm src --exportType default --exportTypeName ClassesType` 191 | 192 | Customize the type name exported in the generated file when `--exportType` is set to `"default"`. 193 | Only default exports are affected by this command. This example will change the export type line to: 194 | 195 | ```typescript 196 | export type ClassesType = keyof Styles; 197 | ``` 198 | 199 | ### `--exportTypeInterface` 200 | 201 | - **Type**: `string` 202 | - **Default**: `"Styles"` 203 | - **Example**: `tlm src --exportType default --exportTypeInterface IStyles` 204 | 205 | Customize the interface name exported in the generated file when `--exportType` is set to `"default"`. 206 | Only default exports are affected by this command. This example will change the export interface line to: 207 | 208 | ```typescript 209 | export type IStyles = { 210 | // ... 211 | }; 212 | ``` 213 | 214 | ### `--quoteType` (`-q`) 215 | 216 | - **Type**: `"single" | "double"` 217 | - **Default**: `"single"` 218 | - **Example**: `tlm src --exportType default --quoteType double` 219 | 220 | Specify a quote type to match your TypeScript configuration. Only default exports are affected by this command. This example will wrap class names with double quotes ("). 221 | 222 | ### `--logLevel` (`-l`) 223 | 224 | - **Type**: `"verbose" | "error" | "info" | "silent"` 225 | - **Default**: `"verbose"` 226 | - **Example**: `tlm src --logLevel error` 227 | 228 | Sets verbosity level of console output. 229 | 230 | ### `--config` (`-c`) 231 | 232 | - **Type**: `string` 233 | - **Default**: `tlm.config.js` 234 | - **Example**: `tlm --config ./path/to/tlm.config.js` 235 | 236 | 指定配置文件的路径,配置文件可代替所有的命令行参数,默认读取 `process.cwd() + tlm.config.js` 文件。 237 | 238 | ```js 239 | // tlm.config.js 240 | const path = require("path"); 241 | 242 | module.exports = { 243 | pattern: "./src/**/*.m.less", 244 | watch: true, 245 | // ... 246 | // 上述所有配置均可用 247 | aliases: { 248 | // 映射至多路径 249 | "~": [ 250 | path.resolve(__dirname, "node_modules"), 251 | path.resolve(__dirname, "src") 252 | ], 253 | // 映射至单路径 254 | "@": path.resolve(__dirname, "some-dir"), 255 | // 自定义映射规则 256 | "abc-module"(filePath) { 257 | return filePath.replace("abc-module", "xxx-path"); 258 | } 259 | }, 260 | // less.render options 参数 261 | lessRenderOptions: { 262 | javascriptEnabled: true 263 | } 264 | }; 265 | ``` 266 | 267 | #### `verbose` 268 | 269 | Print all messages 270 | 271 | #### `error` 272 | 273 | Print only errors 274 | 275 | #### `info` 276 | 277 | Print only some messages 278 | 279 | #### `silent` 280 | 281 | Print nothing 282 | 283 | ## Examples 284 | 285 | For examples, see the `examples` directory: 286 | 287 | - [Basic Example](/examples/basic) 288 | - [Default Export Example](/examples/default-export) 289 | 290 | ## Alternatives 291 | 292 | This package was forked from [typed-scss-modules](https://github.com/skovy/typed-scss-modules). 293 | 294 | This package is currently used as a CLI. There are also [packages that generate types as a webpack loader](https://github.com/Jimdo/typings-for-css-modules-loader). 295 | -------------------------------------------------------------------------------- /__tests__/aliases.less: -------------------------------------------------------------------------------- 1 | @import "~fancy-import"; 2 | @import "~another"; 3 | 4 | .my-custom-class { 5 | color: green; 6 | } 7 | -------------------------------------------------------------------------------- /__tests__/aliases.less.d.ts: -------------------------------------------------------------------------------- 1 | export const someStyles: string; 2 | export const nestedClass: string; 3 | export const nestedAnother: string; 4 | export const someClass: string; 5 | export const myCustomClass: string; 6 | -------------------------------------------------------------------------------- /__tests__/cli.test.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "child_process"; 2 | 3 | describe.only("cli", () => { 4 | it("should run when no files are found", () => { 5 | const result = execSync("yarn tlm src").toString(); 6 | 7 | expect(result).toContain("No files found."); 8 | }); 9 | 10 | describe("examples", () => { 11 | it("should run the basic example without errors", () => { 12 | const result = execSync( 13 | `yarn tlm "examples/basic/**/*.less" --includePaths examples/basic/core --aliases.~alias examples/basic/core/variables` 14 | ).toString(); 15 | 16 | expect(result).toContain("Found 3 files. Generating type definitions..."); 17 | }); 18 | it("should run the default-export example without errors", () => { 19 | const result = execSync( 20 | `yarn tlm "examples/default-export/**/*.less" --exportType default --nameFormat kebab` 21 | ).toString(); 22 | 23 | expect(result).toContain("Found 1 file. Generating type definitions..."); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /__tests__/complex.less: -------------------------------------------------------------------------------- 1 | .some-styles { 2 | color: red; 3 | } 4 | 5 | .nested { 6 | &-class { 7 | background: green; 8 | } 9 | 10 | &-another { 11 | border: 1px; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /__tests__/complex.less.d.ts: -------------------------------------------------------------------------------- 1 | export const someStyles: string; 2 | export const nestedClass: string; 3 | export const nestedAnother: string; 4 | -------------------------------------------------------------------------------- /__tests__/core/alerts.test.ts: -------------------------------------------------------------------------------- 1 | import { alerts, setAlertsLogLevel } from "../../lib/core"; 2 | 3 | describe("alerts", () => { 4 | let logSpy: jest.SpyInstance; 5 | 6 | beforeEach(() => { 7 | logSpy = jest.spyOn(console, "log").mockImplementation(); 8 | }); 9 | 10 | afterEach(() => { 11 | logSpy.mockRestore(); 12 | }); 13 | 14 | const TEST_ALERT_MSG = "TEST ALERT MESSAGE"; 15 | const EXPECTED = expect.stringContaining(TEST_ALERT_MSG); 16 | 17 | test("should print all messages with verbose log level", () => { 18 | setAlertsLogLevel("verbose"); 19 | 20 | alerts.error(TEST_ALERT_MSG); 21 | expect(console.log).toHaveBeenLastCalledWith(EXPECTED); 22 | //make sure each alert only calls console.log once 23 | expect(console.log).toBeCalledTimes(1); 24 | 25 | alerts.warn(TEST_ALERT_MSG); 26 | expect(console.log).toHaveBeenLastCalledWith(EXPECTED); 27 | expect(console.log).toBeCalledTimes(2); 28 | 29 | alerts.notice(TEST_ALERT_MSG); 30 | expect(console.log).toHaveBeenLastCalledWith(EXPECTED); 31 | expect(console.log).toBeCalledTimes(3); 32 | 33 | alerts.info(TEST_ALERT_MSG); 34 | expect(console.log).toHaveBeenLastCalledWith(EXPECTED); 35 | expect(console.log).toBeCalledTimes(4); 36 | 37 | alerts.success(TEST_ALERT_MSG); 38 | expect(console.log).toHaveBeenLastCalledWith(EXPECTED); 39 | expect(console.log).toBeCalledTimes(5); 40 | }); 41 | 42 | test("should only print error messages with error log level", () => { 43 | setAlertsLogLevel("error"); 44 | 45 | alerts.error(TEST_ALERT_MSG); 46 | expect(console.log).toHaveBeenLastCalledWith(EXPECTED); 47 | expect(console.log).toBeCalledTimes(1); 48 | 49 | alerts.warn(TEST_ALERT_MSG); 50 | alerts.notice(TEST_ALERT_MSG); 51 | alerts.info(TEST_ALERT_MSG); 52 | alerts.success(TEST_ALERT_MSG); 53 | //shouldn't change 54 | expect(console.log).toBeCalledTimes(1); 55 | }); 56 | 57 | test("should print all but warning messages with info log level", () => { 58 | setAlertsLogLevel("info"); 59 | 60 | alerts.error(TEST_ALERT_MSG); 61 | expect(console.log).toHaveBeenLastCalledWith(EXPECTED); 62 | expect(console.log).toBeCalledTimes(1); 63 | 64 | alerts.notice(TEST_ALERT_MSG); 65 | expect(console.log).toHaveBeenLastCalledWith(EXPECTED); 66 | expect(console.log).toBeCalledTimes(2); 67 | 68 | alerts.info(TEST_ALERT_MSG); 69 | expect(console.log).toHaveBeenLastCalledWith(EXPECTED); 70 | expect(console.log).toBeCalledTimes(3); 71 | 72 | alerts.success(TEST_ALERT_MSG); 73 | expect(console.log).toHaveBeenLastCalledWith(EXPECTED); 74 | expect(console.log).toBeCalledTimes(4); 75 | 76 | alerts.warn(TEST_ALERT_MSG); 77 | expect(console.log).toBeCalledTimes(4); 78 | }); 79 | 80 | test("should print no messages with silent log level", () => { 81 | setAlertsLogLevel("silent"); 82 | 83 | alerts.error(TEST_ALERT_MSG); 84 | alerts.warn(TEST_ALERT_MSG); 85 | alerts.notice(TEST_ALERT_MSG); 86 | alerts.info(TEST_ALERT_MSG); 87 | alerts.success(TEST_ALERT_MSG); 88 | 89 | expect(console.log).not.toBeCalled(); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /__tests__/core/generate.test.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | import { generate } from "../../lib/core"; 4 | 5 | describe("generate", () => { 6 | beforeEach(() => { 7 | // Only mock the write, so the example files can still be read. 8 | fs.writeFileSync = jest.fn(); 9 | console.log = jest.fn(); // avoid console logs showing up 10 | }); 11 | 12 | test("generates types for all files matching the pattern", async () => { 13 | const pattern = `${__dirname}/../**/*.less`; 14 | 15 | await generate(pattern, { 16 | watch: false, 17 | ignoreInitial: false, 18 | exportType: "named", 19 | exportTypeName: "ClassNames", 20 | exportTypeInterface: "Styles", 21 | listDifferent: false, 22 | ignore: [], 23 | quoteType: "single", 24 | logLevel: "verbose" 25 | }); 26 | 27 | expect(fs.writeFileSync).toBeCalledTimes(5); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /__tests__/core/list-different.test.ts: -------------------------------------------------------------------------------- 1 | import { listDifferent } from "../../lib/core"; 2 | 3 | describe("writeFile", () => { 4 | let exit: jest.SpyInstance; 5 | 6 | beforeEach(() => { 7 | console.log = jest.fn(); 8 | exit = jest.spyOn(process, "exit").mockImplementation(); 9 | }); 10 | 11 | afterEach(() => { 12 | exit.mockRestore(); 13 | }); 14 | 15 | test("logs invalid type definitions and exits with 1", async () => { 16 | const pattern = `${__dirname}/../**/*.less`; 17 | 18 | await listDifferent(pattern, { 19 | watch: false, 20 | ignoreInitial: false, 21 | exportType: "named", 22 | exportTypeName: "ClassNames", 23 | exportTypeInterface: "Styles", 24 | listDifferent: true, 25 | aliases: { 26 | "~fancy-import": `${__dirname}/../complex.less`, 27 | "~another": `${__dirname}/../style.less` 28 | }, 29 | ignore: [], 30 | quoteType: "single", 31 | logLevel: "verbose" 32 | }); 33 | 34 | expect(exit).toHaveBeenCalledWith(1); 35 | expect(console.log).toBeCalledWith( 36 | expect.stringContaining(`[INVALID TYPES] Check type definitions for`) 37 | ); 38 | expect(console.log).toBeCalledWith(expect.stringContaining(`invalid.less`)); 39 | }); 40 | 41 | test("logs nothing and does not exit if all files are valid", async () => { 42 | const pattern = `${__dirname}/../**/style.less`; 43 | 44 | await listDifferent(pattern, { 45 | watch: false, 46 | ignoreInitial: false, 47 | exportType: "named", 48 | exportTypeName: "ClassNames", 49 | exportTypeInterface: "Styles", 50 | listDifferent: true, 51 | ignore: [], 52 | quoteType: "single", 53 | logLevel: "verbose" 54 | }); 55 | 56 | expect(exit).not.toHaveBeenCalled(); 57 | expect(console.log).not.toHaveBeenCalled(); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /__tests__/core/write-file.test.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | import { writeFile } from "../../lib/core"; 4 | import { getTypeDefinitionPath } from "../../lib/typescript"; 5 | 6 | describe("writeFile", () => { 7 | beforeEach(() => { 8 | // Only mock the write, so the example files can still be read. 9 | fs.writeFileSync = jest.fn(); 10 | console.log = jest.fn(); 11 | }); 12 | 13 | test("writes the corresponding type definitions for a file and logs", async () => { 14 | const testFile = `${__dirname}/../style.less`; 15 | const typesFile = getTypeDefinitionPath(testFile); 16 | 17 | await writeFile(testFile, { 18 | watch: false, 19 | ignoreInitial: false, 20 | exportType: "named", 21 | exportTypeName: "ClassNames", 22 | exportTypeInterface: "Styles", 23 | listDifferent: false, 24 | ignore: [], 25 | quoteType: "single", 26 | logLevel: "verbose" 27 | }); 28 | 29 | expect(fs.writeFileSync).toBeCalledWith( 30 | typesFile, 31 | "export const someClass: string;\n" 32 | ); 33 | 34 | expect(console.log).toBeCalledWith( 35 | expect.stringContaining(`[GENERATED TYPES] ${typesFile}`) 36 | ); 37 | }); 38 | 39 | test("it skips files with no classes", async () => { 40 | const testFile = `${__dirname}/../empty.less`; 41 | 42 | await writeFile(testFile, { 43 | watch: false, 44 | ignoreInitial: false, 45 | exportType: "named", 46 | exportTypeName: "ClassNames", 47 | exportTypeInterface: "Styles", 48 | listDifferent: false, 49 | ignore: [], 50 | quoteType: "single", 51 | logLevel: "verbose" 52 | }); 53 | 54 | expect(fs.writeFileSync).not.toBeCalled(); 55 | 56 | expect(console.log).toBeCalledWith( 57 | expect.stringContaining(`[NO GENERATED TYPES] ${testFile}`) 58 | ); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /__tests__/dashes.less: -------------------------------------------------------------------------------- 1 | .App { 2 | background: white; 3 | 4 | .Logo { 5 | width: 50%; 6 | } 7 | } 8 | 9 | .App-Header { 10 | font-size: 2em; 11 | } 12 | -------------------------------------------------------------------------------- /__tests__/dashes.less.d.ts: -------------------------------------------------------------------------------- 1 | export const app: string; 2 | export const logo: string; 3 | export const appHeader: string; 4 | -------------------------------------------------------------------------------- /__tests__/empty.less: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiniu/typed-less-modules/3ed67ccb33d30527ef736f752f08d404d04da805/__tests__/empty.less -------------------------------------------------------------------------------- /__tests__/invalid.less: -------------------------------------------------------------------------------- 1 | .random-class { 2 | color: pink; 3 | } 4 | -------------------------------------------------------------------------------- /__tests__/invalid.less.d.ts: -------------------------------------------------------------------------------- 1 | export const nope: string; 2 | -------------------------------------------------------------------------------- /__tests__/less/file-to-class-names.test.ts: -------------------------------------------------------------------------------- 1 | import { fileToClassNames } from "../../lib/less"; 2 | 3 | describe("fileToClassNames", () => { 4 | test("it converts a file path to an array of class names (default camel cased)", async () => { 5 | const result = await fileToClassNames(`${__dirname}/../complex.less`); 6 | 7 | expect(result).toEqual(["someStyles", "nestedClass", "nestedAnother"]); 8 | }); 9 | 10 | describe("nameFormat", () => { 11 | test("it converts a file path to an array of class names with kebab as the name format", async () => { 12 | const result = await fileToClassNames(`${__dirname}/../complex.less`, { 13 | nameFormat: "kebab" 14 | }); 15 | 16 | expect(result).toEqual(["some-styles", "nested-class", "nested-another"]); 17 | }); 18 | 19 | test("it converts a file path to an array of class names with param as the name format", async () => { 20 | const result = await fileToClassNames(`${__dirname}/../complex.less`, { 21 | nameFormat: "param" 22 | }); 23 | 24 | expect(result).toEqual(["some-styles", "nested-class", "nested-another"]); 25 | }); 26 | 27 | test("it converts a file path to an array of class names where only classes with dashes in the names are altered", async () => { 28 | const result = await fileToClassNames(`${__dirname}/../dashes.less`, { 29 | nameFormat: "dashes" 30 | }); 31 | 32 | expect(result).toEqual(["App", "Logo", "appHeader"]); 33 | }); 34 | 35 | test("it does not change class names when nameFormat is set to none", async () => { 36 | const result = await fileToClassNames(`${__dirname}/../dashes.less`, { 37 | nameFormat: "none" 38 | }); 39 | 40 | expect(result).toEqual(["App", "Logo", "App-Header"]); 41 | }); 42 | }); 43 | 44 | describe("aliases", () => { 45 | test("it converts a file that contains aliases", async () => { 46 | const result = await fileToClassNames(`${__dirname}/../aliases.less`, { 47 | aliases: { 48 | "~fancy-import": `${__dirname}/../complex.less`, 49 | "~another": `${__dirname}/../style.less` 50 | } 51 | }); 52 | 53 | expect(result).toEqual([ 54 | "someStyles", 55 | "nestedClass", 56 | "nestedAnother", 57 | "someClass", 58 | "myCustomClass" 59 | ]); 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /__tests__/main.test.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import slash from "slash"; 3 | 4 | import { main } from "../lib/main"; 5 | 6 | describe("main", () => { 7 | beforeEach(() => { 8 | // Only mock the write, so the example files can still be read. 9 | fs.writeFileSync = jest.fn(); 10 | console.log = jest.fn(); // avoid console logs showing up 11 | }); 12 | 13 | test("generates types for all .less files when the pattern is a directory", async () => { 14 | const pattern = `${__dirname}`; 15 | 16 | await main(pattern, { 17 | watch: false, 18 | ignoreInitial: false, 19 | exportType: "named", 20 | exportTypeName: "ClassNames", 21 | exportTypeInterface: "Styles", 22 | listDifferent: false, 23 | ignore: [], 24 | quoteType: "single", 25 | logLevel: "verbose" 26 | }); 27 | 28 | const expectedDirname = slash(__dirname); 29 | 30 | expect(fs.writeFileSync).toBeCalledTimes(5); 31 | 32 | expect(fs.writeFileSync).toBeCalledWith( 33 | `${expectedDirname}/complex.less.d.ts`, 34 | "export const someStyles: string;\nexport const nestedClass: string;\nexport const nestedAnother: string;\n" 35 | ); 36 | expect(fs.writeFileSync).toBeCalledWith( 37 | `${expectedDirname}/style.less.d.ts`, 38 | "export const someClass: string;\n" 39 | ); 40 | }); 41 | 42 | test("generates types for all .less files and ignores files that match the ignore pattern", async () => { 43 | const pattern = `${__dirname}`; 44 | 45 | await main(pattern, { 46 | watch: false, 47 | ignoreInitial: false, 48 | exportType: "named", 49 | exportTypeName: "ClassNames", 50 | exportTypeInterface: "Styles", 51 | listDifferent: false, 52 | ignore: ["**/style.less"], 53 | quoteType: "single", 54 | logLevel: "verbose" 55 | }); 56 | 57 | expect(fs.writeFileSync).toBeCalledTimes(3); 58 | 59 | const expectedDirname = slash(__dirname); 60 | expect(fs.writeFileSync).toBeCalledWith( 61 | `${expectedDirname}/complex.less.d.ts`, 62 | "export const someStyles: string;\nexport const nestedClass: string;\nexport const nestedAnother: string;\n" 63 | ); 64 | 65 | // Files that should match the ignore pattern. 66 | expect(fs.writeFileSync).not.toBeCalledWith( 67 | `${expectedDirname}/style.less.d.ts`, 68 | expect.anything() 69 | ); 70 | expect(fs.writeFileSync).not.toBeCalledWith( 71 | `${expectedDirname}/nested-styles/style.less.d.ts`, 72 | expect.anything() 73 | ); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /__tests__/nested-styles/style.less: -------------------------------------------------------------------------------- 1 | .nested-styles { 2 | background: purple; 3 | } 4 | -------------------------------------------------------------------------------- /__tests__/nested-styles/style.less.d.ts: -------------------------------------------------------------------------------- 1 | export const nestedStyles: string; 2 | -------------------------------------------------------------------------------- /__tests__/style.less: -------------------------------------------------------------------------------- 1 | .some-class { 2 | color: red; 3 | } 4 | -------------------------------------------------------------------------------- /__tests__/style.less.d.ts: -------------------------------------------------------------------------------- 1 | export const someClass: string; 2 | -------------------------------------------------------------------------------- /__tests__/typescript/class-names-to-type-definitions.test.ts: -------------------------------------------------------------------------------- 1 | import { classNamesToTypeDefinitions, ExportType } from "../../lib/typescript"; 2 | 3 | describe("classNamesToTypeDefinitions", () => { 4 | beforeEach(() => { 5 | console.log = jest.fn(); 6 | }); 7 | 8 | describe("named", () => { 9 | it("converts an array of class name strings to type definitions", () => { 10 | const definition = classNamesToTypeDefinitions({ 11 | classNames: ["myClass", "yourClass"], 12 | exportType: "named" 13 | }); 14 | 15 | expect(definition).toEqual( 16 | "export const myClass: string;\nexport const yourClass: string;\n" 17 | ); 18 | }); 19 | 20 | it("returns null if there are no class names", () => { 21 | const definition = classNamesToTypeDefinitions({ 22 | classNames: [], 23 | exportType: "named" 24 | }); 25 | 26 | expect(definition).toBeNull; 27 | }); 28 | 29 | it("prints a warning if a classname is a reserved keyword and does not include it in the type definitions", () => { 30 | const definition = classNamesToTypeDefinitions({ 31 | classNames: ["myClass", "if"], 32 | exportType: "named" 33 | }); 34 | 35 | expect(definition).toEqual("export const myClass: string;\n"); 36 | expect(console.log).toBeCalledWith( 37 | expect.stringContaining(`[SKIPPING] 'if' is a reserved keyword`) 38 | ); 39 | }); 40 | 41 | it("prints a warning if a classname is invalid and does not include it in the type definitions", () => { 42 | const definition = classNamesToTypeDefinitions({ 43 | classNames: ["myClass", "invalid-variable"], 44 | exportType: "named" 45 | }); 46 | 47 | expect(definition).toEqual("export const myClass: string;\n"); 48 | expect(console.log).toBeCalledWith( 49 | expect.stringContaining(`[SKIPPING] 'invalid-variable' contains dashes`) 50 | ); 51 | }); 52 | }); 53 | 54 | describe("default", () => { 55 | it("converts an array of class name strings to type definitions", () => { 56 | const definition = classNamesToTypeDefinitions({ 57 | classNames: ["myClass", "yourClass"], 58 | exportType: "default" 59 | }); 60 | 61 | expect(definition).toEqual( 62 | "export type Styles = {\n 'myClass': string;\n 'yourClass': string;\n}\n\nexport type ClassNames = keyof Styles;\n\ndeclare const styles: Styles;\n\nexport default styles;\n" 63 | ); 64 | }); 65 | 66 | it("returns null if there are no class names", () => { 67 | const definition = classNamesToTypeDefinitions({ 68 | classNames: [], 69 | exportType: "default" 70 | }); 71 | 72 | expect(definition).toBeNull; 73 | }); 74 | }); 75 | 76 | describe("invalid export type", () => { 77 | it("returns null", () => { 78 | const definition = classNamesToTypeDefinitions({ 79 | classNames: ["myClass"], 80 | exportType: "invalid" as ExportType 81 | }); 82 | 83 | expect(definition).toBeNull; 84 | }); 85 | }); 86 | 87 | describe("quoteType", () => { 88 | it("uses double quotes for default exports when specified", () => { 89 | const definition = classNamesToTypeDefinitions({ 90 | classNames: ["myClass", "yourClass"], 91 | exportType: "default", 92 | quoteType: "double" 93 | }); 94 | 95 | expect(definition).toEqual( 96 | 'export type Styles = {\n "myClass": string;\n "yourClass": string;\n}\n\nexport type ClassNames = keyof Styles;\n\ndeclare const styles: Styles;\n\nexport default styles;\n' 97 | ); 98 | }); 99 | 100 | it("does not affect named exports", () => { 101 | const definition = classNamesToTypeDefinitions({ 102 | classNames: ["myClass", "yourClass"], 103 | exportType: "named", 104 | quoteType: "double" 105 | }); 106 | 107 | expect(definition).toEqual( 108 | "export const myClass: string;\nexport const yourClass: string;\n" 109 | ); 110 | }); 111 | }); 112 | 113 | describe("exportType name and type attributes", () => { 114 | it("uses custom value for ClassNames type name", () => { 115 | const definition = classNamesToTypeDefinitions({ 116 | classNames: ["myClass", "yourClass"], 117 | exportType: "default", 118 | exportTypeName: "Classes" 119 | }); 120 | 121 | expect(definition).toEqual( 122 | "export type Styles = {\n 'myClass': string;\n 'yourClass': string;\n}\n\nexport type Classes = keyof Styles;\n\ndeclare const styles: Styles;\n\nexport default styles;\n" 123 | ); 124 | }); 125 | 126 | it("uses custom value for Styles type name", () => { 127 | const definition = classNamesToTypeDefinitions({ 128 | classNames: ["myClass", "yourClass"], 129 | exportType: "default", 130 | exportTypeInterface: "IStyles" 131 | }); 132 | 133 | expect(definition).toEqual( 134 | "export type IStyles = {\n 'myClass': string;\n 'yourClass': string;\n}\n\nexport type ClassNames = keyof IStyles;\n\ndeclare const styles: IStyles;\n\nexport default styles;\n" 135 | ); 136 | }); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /__tests__/typescript/get-type-definition-path.test.ts: -------------------------------------------------------------------------------- 1 | import { getTypeDefinitionPath } from "../../lib/typescript"; 2 | 3 | describe("getTypeDefinitionPath", () => { 4 | it("returns the type definition path", () => { 5 | const path = getTypeDefinitionPath("/some/path/style.less"); 6 | 7 | expect(path).toEqual("/some/path/style.less.d.ts"); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | bot: "codecov" 3 | ci: 4 | - "travis.org" 5 | require_ci_to_pass: yes 6 | 7 | coverage: 8 | status: 9 | patch: off 10 | project: off 11 | 12 | comment: 13 | layout: "reach, diff, flags, files" 14 | behavior: default 15 | branches: 16 | - "master" 17 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ["@commitlint/config-conventional"] }; 2 | -------------------------------------------------------------------------------- /docs/typed-less-modules-example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiniu/typed-less-modules/3ed67ccb33d30527ef736f752f08d404d04da805/docs/typed-less-modules-example.gif -------------------------------------------------------------------------------- /docs/typed-less-modules-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiniu/typed-less-modules/3ed67ccb33d30527ef736f752f08d404d04da805/docs/typed-less-modules-example.png -------------------------------------------------------------------------------- /examples/basic/README.md: -------------------------------------------------------------------------------- 1 | # Basic Example 2 | 3 | This example contains: 4 | 5 | - Core variables (`core/variables.less`) which contains things like colors, etc. To make the import of these variables easier, it's expected that this directory is included in the search path. This demonstrates the need for `includePaths`. 6 | - An alias. This is most common when using a [webpack alias](https://webpack.js.org/configuration/resolve/#resolve-alias). This demonstrates the need for `aliases`. 7 | 8 | The command to generate the proper type files would look like this (_in the root of this repository_): 9 | 10 | ```bash 11 | yarn tlm "examples/basic/**/*.less" --includePaths examples/basic/core --aliases.~alias examples/basic/core/variables 12 | ``` 13 | 14 | - The glob pattern is wrapped in quotes to pass it as a string and avoid executing. 15 | - `includePaths` with `examples/basic/core` so that `@import 'variables'` is found. 16 | - `aliases` with `~alias: variables` meaning any `@import '~alias'` resolves to `@import 'variables'`. 17 | - No file will be output for `variables.less` since there are no classes. 18 | -------------------------------------------------------------------------------- /examples/basic/core/variables.less: -------------------------------------------------------------------------------- 1 | @blue: blue; 2 | @yellow: yellow; 3 | -------------------------------------------------------------------------------- /examples/basic/feature-a/index.ts: -------------------------------------------------------------------------------- 1 | import styles from "./style.less"; 2 | 3 | console.log(styles.text); 4 | console.log(styles.textHighlighted); 5 | // console.log(styles.somethingElse) <- Invalid: Property 'somethingElse' does not exist 6 | -------------------------------------------------------------------------------- /examples/basic/feature-a/style.less: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | 3 | .text { 4 | color: @blue; 5 | 6 | &-highlighted { 7 | color: @yellow; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /examples/basic/feature-a/style.less.d.ts: -------------------------------------------------------------------------------- 1 | export const text: string; 2 | export const textHighlighted: string; 3 | -------------------------------------------------------------------------------- /examples/basic/feature-b/index.ts: -------------------------------------------------------------------------------- 1 | import styles from "./style.less"; 2 | 3 | console.log(styles.topBanner); 4 | // console.log(styles.somethingElse) <- Invalid: Property 'somethingElse' does not exist 5 | -------------------------------------------------------------------------------- /examples/basic/feature-b/style.less: -------------------------------------------------------------------------------- 1 | @import "~alias"; 2 | 3 | .top-banner { 4 | background: @yellow; 5 | } 6 | -------------------------------------------------------------------------------- /examples/basic/feature-b/style.less.d.ts: -------------------------------------------------------------------------------- 1 | export const topBanner: string; 2 | -------------------------------------------------------------------------------- /examples/default-export/README.md: -------------------------------------------------------------------------------- 1 | # Default Export Example 2 | 3 | This example contains: 4 | 5 | - Class names that are expected to be kebab (param) cased. Since variables cannot contain a `-` this can be achieved using a type with default export. 6 | - Class names that are TypeScript keywords (eg: `while`) that cannot be used as named constants. 7 | 8 | The command to generate the proper type files would look like this (_in the root of this repository_): 9 | 10 | ```bash 11 | yarn tlm "examples/default-export/**/*.less" --exportType default --nameFormat kebab 12 | ``` 13 | -------------------------------------------------------------------------------- /examples/default-export/feature-a/index.ts: -------------------------------------------------------------------------------- 1 | import styles, { Styles, ClassNames } from "./style.less"; 2 | 3 | console.log(styles.i); 4 | console.log(styles["i-am-kebab-cased"]); 5 | 6 | // Using the ClassNames union type to assign class names. 7 | const className: ClassNames = "i-am-kebab-cased"; 8 | 9 | // Using the Styles type for reconstructing a subset. 10 | export const classNames: Partial = { 11 | [className]: "something", 12 | i: "a-string" 13 | }; 14 | -------------------------------------------------------------------------------- /examples/default-export/feature-a/style.less: -------------------------------------------------------------------------------- 1 | .i { 2 | color: orange; 3 | 4 | &-am { 5 | &-kebab { 6 | &-cased { 7 | color: red; 8 | } 9 | } 10 | } 11 | } 12 | 13 | .while { 14 | color: blue; 15 | } 16 | -------------------------------------------------------------------------------- /examples/default-export/feature-a/style.less.d.ts: -------------------------------------------------------------------------------- 1 | export type Styles = { 2 | i: string; 3 | "i-am-kebab-cased": string; 4 | while: string; 5 | }; 6 | 7 | export type ClassNames = keyof Styles; 8 | 9 | declare const styles: Styles; 10 | 11 | export default styles; 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | testMatch: ["**/__tests__/**/*.test.ts?(x)"], 5 | testPathIgnorePatterns: [ 6 | "/dist/", 7 | "/node_modules/", 8 | "(.*).d.ts" 9 | ], 10 | collectCoverage: true, 11 | collectCoverageFrom: ["lib/**/*.(ts|tsx)", "!**/node_modules/**"], 12 | coverageDirectory: "__coverage__", 13 | coverageReporters: ["json", "lcov", "text", "cobertura"] 14 | }; 15 | -------------------------------------------------------------------------------- /less.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Less { 2 | interface FileContent { 3 | filename: string; 4 | contents: string; 5 | } 6 | 7 | interface FileContentWithError { 8 | error: Error; 9 | filename: string; 10 | contents: string; 11 | } 12 | 13 | interface FileManager { 14 | new (): FileManager; 15 | getPath?(filename: string): string; 16 | tryAppendLessExtension?(filename: string): string; 17 | alwaysMakePathsAbsolute?(): boolean; 18 | isPathAbsolute(path: string): boolean; 19 | join?(basePath: string, laterPath: string): string; 20 | pathDiff?(path: string, basePath: string): string; 21 | supportsSync?( 22 | filename: string, 23 | currentDirectory: string, 24 | options: Record, 25 | environment: unknown 26 | ): boolean; 27 | supports?( 28 | filename: string, 29 | currentDirectory: string, 30 | options: Record, 31 | environment: unknown 32 | ): boolean; 33 | loadFile( 34 | filename: string, 35 | currentDirectory: string, 36 | options: Record, 37 | environment: unknown, 38 | callback: Function 39 | ): Promise; 40 | loadFileSync( 41 | filename: string, 42 | currentDirectory: string, 43 | options: Record, 44 | environment: unknown, 45 | callback: Function 46 | ): FileContentWithError; 47 | } 48 | 49 | interface PluginManager { 50 | addFileManager(manager: Less.FileManager): void; 51 | } 52 | } 53 | 54 | interface LessStatic { 55 | FileManager: Less.FileManager; 56 | } 57 | -------------------------------------------------------------------------------- /lib/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import yargs from "yargs"; 4 | 5 | import { 6 | nameFormatDefault, 7 | configFilePathDefault, 8 | Aliases, 9 | NAME_FORMATS 10 | } from "./less"; 11 | import { 12 | exportTypeDefault, 13 | exportTypeInterfaceDefault, 14 | exportTypeNameDefault, 15 | quoteTypeDefault, 16 | logLevelDefault, 17 | EXPORT_TYPES, 18 | QUOTE_TYPES, 19 | LOG_LEVELS 20 | } from "./typescript"; 21 | import { main } from "./main"; 22 | 23 | const { _: patterns, ...rest } = yargs 24 | .usage( 25 | "Generate .less.d.ts from CSS module .less files.\nUsage: $0 [options]" 26 | ) 27 | .example("$0 src", "All .less files at any level in the src directory") 28 | .example( 29 | "$0 src/**/*.less", 30 | "All .less files at any level in the src directory" 31 | ) 32 | .example( 33 | "$0 src/**/*.less --watch", 34 | "Watch all .less files at any level in the src directory that are added or changed" 35 | ) 36 | .example( 37 | "$0 src/**/*.less --includePaths src/core src/variables", 38 | 'Search the "core" and "variables" directory when resolving imports' 39 | ) 40 | .example( 41 | "$0 src/**/*.less --aliases.~name variables", 42 | 'Replace all imports for "~name" with "variables"' 43 | ) 44 | .example( 45 | "$0 src/**/*.less --aliases.~ ./node_modules/", 46 | 'Replace all imports for "~" with "./node_modules/"' 47 | ) 48 | .example( 49 | "$0 src/**/*.less --ignore **/secret.less", 50 | 'Ignore any file names "secret.less"' 51 | ) 52 | .example( 53 | "$0 src/**/*.less -e default --quoteType double", 54 | "Use double quotes around class name definitions rather than single quotes." 55 | ) 56 | .example("$0 src/**/*.less --logLevel error", "Output only errors") 57 | 58 | .option("config", { 59 | string: true, 60 | alias: "c", 61 | default: configFilePathDefault, 62 | describe: `Specify the configuration file. default: ${configFilePathDefault}.` 63 | }) 64 | .option("aliases", { 65 | coerce: (obj): Aliases => obj, 66 | alias: "a", 67 | describe: "Alias any import to any other value." 68 | }) 69 | .option("nameFormat", { 70 | choices: NAME_FORMATS, 71 | default: nameFormatDefault, 72 | alias: "n", 73 | describe: "The name format that should be used to transform class names." 74 | }) 75 | .option("exportType", { 76 | choices: EXPORT_TYPES, 77 | default: exportTypeDefault, 78 | alias: "e", 79 | describe: "The type of export used for defining the type definitions." 80 | }) 81 | .option("exportTypeName", { 82 | string: true, 83 | default: exportTypeNameDefault, 84 | describe: 85 | 'Set a custom type name for styles when --exportType is "default."' 86 | }) 87 | .option("exportTypeInterface", { 88 | string: true, 89 | default: exportTypeInterfaceDefault, 90 | describe: 91 | 'Set a custom interface name for styles when --exportType is "default."' 92 | }) 93 | .option("watch", { 94 | boolean: true, 95 | default: false, 96 | alias: "w", 97 | describe: 98 | "Watch for added or changed files and (re-)generate the type definitions." 99 | }) 100 | .option("ignoreInitial", { 101 | boolean: true, 102 | default: false, 103 | describe: "Skips the initial build when passing the watch flag." 104 | }) 105 | .option("listDifferent", { 106 | boolean: true, 107 | default: false, 108 | alias: "l", 109 | describe: 110 | "List any type definitions that are different than those that would be generated." 111 | }) 112 | .option("includePaths", { 113 | array: true, 114 | string: true, 115 | alias: "i", 116 | describe: "Additional paths to include when trying to resolve imports." 117 | }) 118 | .option("ignore", { 119 | string: true, 120 | array: true, 121 | default: [], 122 | describe: "Add a pattern or an array of glob patterns to exclude matches." 123 | }) 124 | .options("quoteType", { 125 | string: true, 126 | choices: QUOTE_TYPES, 127 | default: quoteTypeDefault, 128 | alias: "q", 129 | describe: 130 | "Specify the quote type so that generated files adhere to your TypeScript rules." 131 | }) 132 | .option("logLevel", { 133 | string: true, 134 | choices: LOG_LEVELS, 135 | default: logLevelDefault, 136 | alias: "L", 137 | describe: "Verbosity level of console output" 138 | }).argv; 139 | 140 | main(patterns[0], { ...rest }); 141 | -------------------------------------------------------------------------------- /lib/core/alerts.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | 3 | export const LOG_LEVELS = ["verbose", "error", "info", "silent"] as const; 4 | export type LogLevel = typeof LOG_LEVELS[number]; 5 | 6 | export const logLevelDefault: LogLevel = "verbose"; 7 | 8 | let currentLogLevel: LogLevel | undefined; 9 | 10 | export const setAlertsLogLevel = (logLevel: LogLevel) => { 11 | currentLogLevel = logLevel; 12 | }; 13 | 14 | type CbFunc = (...args: any) => any; 15 | type WrappedCbFunc = ( 16 | ...args: Parameters 17 | ) => ReturnType | void; 18 | /** 19 | * wraps a callback and only calls it if currentLogLevel is undefined or included in permittedLogLevels 20 | * @param permittedLogLevels list of log levels. callbacks will only be called if current log level is listed here 21 | * @param cb callback 22 | */ 23 | const withLogLevelsRestriction = ( 24 | permittedLogLevels: LogLevel[], 25 | cb: T 26 | ): WrappedCbFunc => (...args: Parameters): ReturnType | void => { 27 | const shouldCall = 28 | !currentLogLevel || permittedLogLevels.includes(currentLogLevel); 29 | 30 | if (shouldCall) { 31 | return cb(...args); 32 | } 33 | }; 34 | 35 | const error = withLogLevelsRestriction( 36 | ["verbose", "error", "info"], 37 | (message: string) => console.log(chalk.red(message)) 38 | ); 39 | const warn = withLogLevelsRestriction(["verbose"], (message: string) => 40 | console.log(chalk.yellowBright(message)) 41 | ); 42 | const notice = withLogLevelsRestriction( 43 | ["verbose", "info"], 44 | (message: string) => console.log(chalk.gray(message)) 45 | ); 46 | const info = withLogLevelsRestriction(["verbose", "info"], (message: string) => 47 | console.log(chalk.blueBright(message)) 48 | ); 49 | const success = withLogLevelsRestriction( 50 | ["verbose", "info"], 51 | (message: string) => console.log(chalk.green(message)) 52 | ); 53 | 54 | export const alerts = { error, warn, notice, info, success }; 55 | -------------------------------------------------------------------------------- /lib/core/generate.ts: -------------------------------------------------------------------------------- 1 | import glob from "glob"; 2 | 3 | import { alerts } from "./alerts"; 4 | import { MainOptions } from "./types"; 5 | import { writeFile } from "./write-file"; 6 | 7 | /** 8 | * Given a file glob generate the corresponding types once. 9 | * 10 | * @param pattern the file pattern to generate type definitions for 11 | * @param options the CLI options 12 | */ 13 | export const generate = async ( 14 | pattern: string, 15 | options: MainOptions 16 | ): Promise => { 17 | // Find all the files that match the provided pattern. 18 | const files = glob.sync(pattern, { ignore: options.ignore }); 19 | 20 | if (!files || !files.length) { 21 | alerts.error("No files found."); 22 | return; 23 | } 24 | 25 | // This case still works as expected but it's easy to do on accident so 26 | // provide a (hopefully) helpful warning. 27 | if (files.length === 1) { 28 | alerts.warn( 29 | `Only 1 file found for ${pattern}. If using a glob pattern (eg: dir/**/*.less) make sure to wrap in quotes (eg: "dir/**/*.less").` 30 | ); 31 | } 32 | 33 | alerts.success( 34 | `Found ${files.length} file${ 35 | files.length === 1 ? `` : `s` 36 | }. Generating type definitions...` 37 | ); 38 | 39 | // Wait for all the type definitions to be written. 40 | await Promise.all(files.map(file => writeFile(file, options))); 41 | }; 42 | -------------------------------------------------------------------------------- /lib/core/index.ts: -------------------------------------------------------------------------------- 1 | export { alerts, setAlertsLogLevel } from "./alerts"; 2 | export { generate } from "./generate"; 3 | export { listDifferent } from "./list-different"; 4 | export { MainOptions } from "./types"; 5 | export { watch } from "./watch"; 6 | export { writeFile } from "./write-file"; 7 | -------------------------------------------------------------------------------- /lib/core/list-different.ts: -------------------------------------------------------------------------------- 1 | import glob from "glob"; 2 | import fs from "fs"; 3 | 4 | import { alerts } from "./alerts"; 5 | import { MainOptions } from "./types"; 6 | import { fileToClassNames } from "../less"; 7 | import { 8 | classNamesToTypeDefinitions, 9 | getTypeDefinitionPath 10 | } from "../typescript"; 11 | 12 | export const listDifferent = async ( 13 | pattern: string, 14 | options: MainOptions 15 | ): Promise => { 16 | // Find all the files that match the provided pattern. 17 | const files = glob.sync(pattern); 18 | 19 | if (!files || !files.length) { 20 | alerts.notice("No files found."); 21 | return; 22 | } 23 | 24 | // Wait for all the files to be checked. 25 | await Promise.all(files.map(file => checkFile(file, options))).then( 26 | results => { 27 | results.includes(false) && process.exit(1); 28 | } 29 | ); 30 | }; 31 | 32 | export const checkFile = ( 33 | file: string, 34 | options: MainOptions 35 | ): Promise => { 36 | return new Promise(resolve => 37 | fileToClassNames(file, options).then(classNames => { 38 | const typeDefinition = classNamesToTypeDefinitions({ 39 | classNames: classNames, 40 | ...options 41 | }); 42 | 43 | if (!typeDefinition) { 44 | // Assume if no type defs are necessary it's fine 45 | resolve(true); 46 | return; 47 | } 48 | 49 | const path = getTypeDefinitionPath(file); 50 | 51 | const content = fs.readFileSync(path, { encoding: "UTF8" }); 52 | 53 | if (content === typeDefinition) { 54 | resolve(true); 55 | } else { 56 | alerts.error(`[INVALID TYPES] Check type definitions for ${file}`); 57 | resolve(false); 58 | } 59 | }) 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /lib/core/types.ts: -------------------------------------------------------------------------------- 1 | import { Options } from "../less"; 2 | import { ExportType, QuoteType, LogLevel } from "../typescript"; 3 | 4 | export interface MainOptions extends Options { 5 | pattern?: string; 6 | config?: string; 7 | lessRenderOptions?: Less.Options; 8 | ignore: string[]; 9 | ignoreInitial: boolean; 10 | exportType: ExportType; 11 | exportTypeName: string; 12 | exportTypeInterface: string; 13 | listDifferent: boolean; 14 | quoteType: QuoteType; 15 | watch: boolean; 16 | logLevel: LogLevel; 17 | } 18 | -------------------------------------------------------------------------------- /lib/core/watch.ts: -------------------------------------------------------------------------------- 1 | import chokidar from "chokidar"; 2 | 3 | import { alerts } from "./alerts"; 4 | import { writeFile } from "./write-file"; 5 | import { MainOptions } from "./types"; 6 | 7 | /** 8 | * Watch a file glob and generate the corresponding types. 9 | * 10 | * @param pattern the file pattern to watch for file changes or additions 11 | * @param options the CLI options 12 | */ 13 | export const watch = (pattern: string, options: MainOptions): void => { 14 | alerts.success("Watching files..."); 15 | 16 | chokidar 17 | .watch(pattern, { 18 | ignoreInitial: options.ignoreInitial, 19 | ignored: options.ignore 20 | }) 21 | .on("change", path => { 22 | alerts.info(`[CHANGED] ${path}`); 23 | writeFile(path, options); 24 | }) 25 | .on("add", path => { 26 | alerts.info(`[ADDED] ${path}`); 27 | writeFile(path, options); 28 | }); 29 | }; 30 | -------------------------------------------------------------------------------- /lib/core/write-file.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | import { alerts } from "./alerts"; 4 | import { 5 | getTypeDefinitionPath, 6 | classNamesToTypeDefinitions 7 | } from "../typescript"; 8 | import { fileToClassNames } from "../less"; 9 | import { MainOptions } from "./types"; 10 | 11 | /** 12 | * Given a single file generate the proper types. 13 | * 14 | * @param file the LESS file to generate types for 15 | * @param options the CLI options 16 | */ 17 | export const writeFile = ( 18 | file: string, 19 | options: MainOptions 20 | ): Promise => { 21 | return fileToClassNames(file, options) 22 | .then(classNames => { 23 | const typeDefinition = classNamesToTypeDefinitions({ 24 | classNames: classNames, 25 | ...options 26 | }); 27 | 28 | if (!typeDefinition) { 29 | alerts.notice(`[NO GENERATED TYPES] ${file}`); 30 | return; 31 | } 32 | 33 | const path = getTypeDefinitionPath(file); 34 | 35 | fs.writeFileSync(path, typeDefinition); 36 | alerts.success(`[GENERATED TYPES] ${path}`); 37 | }) 38 | .catch(({ message, filename, line, column }: Less.RenderError) => { 39 | const location = filename ? `(${filename}[${line}:${column}])` : ""; 40 | alerts.error(`${message} ${location}`); 41 | }); 42 | }; 43 | -------------------------------------------------------------------------------- /lib/less/aliases-plugin.ts: -------------------------------------------------------------------------------- 1 | // fork form https://github.com/dancon/less-plugin-aliases 2 | 3 | import fs from "fs"; 4 | import path from "path"; 5 | import { Aliases } from "./file-to-class-names"; 6 | 7 | const checkExtList = [".less", ".css"]; 8 | 9 | function normalizePath(filename: string) { 10 | if (/\.(?:less|css)$/i.test(filename)) { 11 | return fs.existsSync(filename) ? filename : undefined; 12 | } 13 | 14 | for (let i = 0, len = checkExtList.length; i < len; i++) { 15 | const ext = checkExtList[i]; 16 | if (fs.existsSync(`${filename}${ext}`)) { 17 | return `${filename}${ext}`; 18 | } 19 | } 20 | } 21 | 22 | export class LessAliasesPlugin { 23 | constructor(private aliases: Aliases) {} 24 | 25 | install(less: LessStatic, pluginManager: Less.PluginManager) { 26 | const { aliases = {} } = this; 27 | 28 | function resolve(filename: string) { 29 | // 从长到短排序可以有效避免 `a` 和 `ab` 别名冲突的问题 30 | const aliasNames = Object.keys(aliases).sort( 31 | (a, b) => b.length - a.length 32 | ); 33 | 34 | // 没有设置别名 -> 输出原始文件 35 | if (!aliasNames.length) { 36 | return filename; 37 | } 38 | 39 | // 匹配别名 -> 输出匹配别名 40 | // 有匹配项 & 未正确解析 -> 输出错误(用于处理同时设置了 '~' & '~~' 的情况) 41 | let isHited = false; 42 | let resolvedPath: string | undefined; 43 | 44 | for (let i = 0; i < aliasNames.length; i++) { 45 | const aliasName = aliasNames[i]; 46 | 47 | if (filename.startsWith(aliasName)) { 48 | isHited = true; 49 | const targetAliasPath = aliases[aliasName]; 50 | const targetFileRestPath = filename.substr(aliasName.length); 51 | 52 | // key: (filePath) => newFilePath 53 | if (typeof targetAliasPath === "function") { 54 | resolvedPath = normalizePath(targetAliasPath(filename)); 55 | // key: path 56 | } else if (typeof targetAliasPath === "string") { 57 | resolvedPath = normalizePath( 58 | path.join(targetAliasPath, targetFileRestPath) 59 | ); 60 | // key: [path, path] 61 | } else if (Array.isArray(targetAliasPath)) { 62 | for (let i = 0; i < targetAliasPath.length; i++) { 63 | resolvedPath = normalizePath( 64 | path.join(targetAliasPath[i], targetFileRestPath) 65 | ); 66 | if (resolvedPath) { 67 | return resolvedPath; 68 | } 69 | } 70 | } 71 | 72 | if (resolvedPath) { 73 | return resolvedPath; 74 | } 75 | } 76 | } 77 | 78 | if (isHited && !resolvedPath) { 79 | throw new Error(`Invalid @import: ${filename}`); 80 | } 81 | } 82 | 83 | function resolveFile(filename: string) { 84 | let resolved; 85 | try { 86 | resolved = resolve(filename); 87 | } catch (error) { 88 | console.error(error); 89 | } 90 | if (!resolved) { 91 | throw new Error( 92 | `[typed-less-modules:aliases-plugin]: '${filename}' not found.` 93 | ); 94 | } 95 | return resolved; 96 | } 97 | 98 | class AliasePlugin extends less.FileManager { 99 | supports(filename: string, currentDirectory: string) { 100 | const aliasNames = Object.keys(aliases); 101 | 102 | for (let i = 0; i < aliasNames.length; i++) { 103 | const aliasName = aliasNames[i]; 104 | if ( 105 | filename.indexOf(aliasName) !== -1 || 106 | currentDirectory.indexOf(aliasName) !== -1 107 | ) { 108 | return true; 109 | } 110 | } 111 | return false; 112 | } 113 | 114 | supportsSync(filename: string, currentDirectory: string) { 115 | return this.supports(filename, currentDirectory); 116 | } 117 | 118 | loadFile( 119 | filename: string, 120 | currentDirectory: string, 121 | options: Record, 122 | enviroment: unknown, 123 | callback: Function 124 | ) { 125 | return super.loadFile( 126 | resolveFile(filename), 127 | currentDirectory, 128 | options, 129 | enviroment, 130 | callback 131 | ); 132 | } 133 | 134 | loadFileSync( 135 | filename: string, 136 | currentDirectory: string, 137 | options: Record, 138 | enviroment: unknown, 139 | callback: Function 140 | ) { 141 | return super.loadFileSync( 142 | resolveFile(filename), 143 | currentDirectory, 144 | options, 145 | enviroment, 146 | callback 147 | ); 148 | } 149 | } 150 | 151 | pluginManager.addFileManager(new AliasePlugin()); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /lib/less/file-to-class-names.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import less from "less"; 4 | import camelcase from "camelcase"; 5 | import { paramCase } from "param-case"; 6 | 7 | import { sourceToClassNames } from "./source-to-class-names"; 8 | import { LessAliasesPlugin } from "./aliases-plugin"; 9 | import { MainOptions } from "../core"; 10 | 11 | export type ClassName = string; 12 | export type ClassNames = ClassName[]; 13 | 14 | type AliasesFunc = (filePath: string) => string; 15 | export type Aliases = Record; 16 | 17 | export type NameFormat = "camel" | "kebab" | "param" | "dashes" | "none"; 18 | 19 | export interface Options { 20 | includePaths?: string[]; 21 | aliases?: Aliases; 22 | nameFormat?: NameFormat; 23 | } 24 | 25 | export const NAME_FORMATS: NameFormat[] = [ 26 | "camel", 27 | "kebab", 28 | "param", 29 | "dashes", 30 | "none" 31 | ]; 32 | 33 | export const nameFormatDefault: NameFormat = "camel"; 34 | export const configFilePathDefault: string = "tlm.config.js"; 35 | 36 | // Options 这里实际上传递的是 MainOptions 37 | export const fileToClassNames = async ( 38 | file: string, 39 | options: Options = {} as MainOptions 40 | ): Promise => { 41 | // options 42 | const aliases = options.aliases || {}; 43 | const includePaths = options.includePaths || []; 44 | const nameFormat = options.nameFormat || "camel"; 45 | const lessRenderOptions = (options as MainOptions).lessRenderOptions || {}; 46 | 47 | // less render 48 | const transformer = classNameTransformer(nameFormat); 49 | const fileContent = fs.readFileSync(file, "UTF-8"); 50 | const result = await less.render(fileContent, { 51 | filename: path.resolve(file), 52 | paths: includePaths, 53 | syncImport: true, 54 | plugins: [new LessAliasesPlugin(aliases)], 55 | ...lessRenderOptions 56 | }); 57 | 58 | // get classnames 59 | const { exportTokens } = await sourceToClassNames(result.css); 60 | const classNames = Object.keys(exportTokens); 61 | const transformedClassNames = classNames.map(transformer); 62 | return transformedClassNames; 63 | }; 64 | 65 | interface Transformer { 66 | (className: string): string; 67 | } 68 | 69 | const classNameTransformer = (nameFormat: NameFormat): Transformer => { 70 | switch (nameFormat) { 71 | case "kebab": 72 | case "param": 73 | return className => paramCase(className); 74 | case "camel": 75 | return className => camelcase(className); 76 | case "dashes": 77 | return className => 78 | /-/.test(className) ? camelcase(className) : className; 79 | case "none": 80 | return className => className; 81 | } 82 | }; 83 | -------------------------------------------------------------------------------- /lib/less/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | nameFormatDefault, 3 | configFilePathDefault, 4 | fileToClassNames, 5 | Aliases, 6 | NameFormat, 7 | Options, 8 | NAME_FORMATS 9 | } from "./file-to-class-names"; 10 | -------------------------------------------------------------------------------- /lib/less/source-to-class-names.ts: -------------------------------------------------------------------------------- 1 | import Core, { Source } from "css-modules-loader-core"; 2 | 3 | const core = new Core(); 4 | 5 | export const sourceToClassNames = (source: Source) => { 6 | return core.load(source); 7 | }; 8 | -------------------------------------------------------------------------------- /lib/main.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import slash from "slash"; 4 | 5 | import { 6 | watch, 7 | MainOptions, 8 | generate, 9 | listDifferent, 10 | setAlertsLogLevel 11 | } from "./core"; 12 | 13 | export const main = async (pattern: string, options: MainOptions) => { 14 | // config file 15 | const configFilePath = path.join(process.cwd(), "tlm.config.js"); 16 | if (options.config && fs.existsSync(configFilePath)) { 17 | options = { 18 | ...options, 19 | ...require(configFilePath) 20 | }; 21 | if (options.pattern) { 22 | pattern = options.pattern; 23 | } 24 | } 25 | 26 | setAlertsLogLevel(options.logLevel); 27 | 28 | // When the provided pattern is a directory construct the proper glob to find 29 | // all .less files within that directory. Also, add the directory to the 30 | // included paths so any imported with a path relative to the root of the 31 | // project still works as expected without adding many include paths. 32 | if (fs.existsSync(pattern) && fs.lstatSync(pattern).isDirectory()) { 33 | if (Array.isArray(options.includePaths)) { 34 | options.includePaths.push(pattern); 35 | } else { 36 | options.includePaths = [pattern]; 37 | } 38 | 39 | // When the pattern provide is a directory, assume all .less files within. 40 | pattern = slash(path.resolve(pattern, "**/*.less")); 41 | } 42 | 43 | if (options.listDifferent) { 44 | listDifferent(pattern, options); 45 | return; 46 | } 47 | 48 | if (options.watch) { 49 | watch(pattern, options); 50 | } else { 51 | await generate(pattern, options); 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /lib/typescript/class-names-to-type-definition.ts: -------------------------------------------------------------------------------- 1 | import reserved from "reserved-words"; 2 | 3 | import { ClassNames, ClassName } from "lib/less/file-to-class-names"; 4 | import { alerts } from "../core"; 5 | 6 | export type ExportType = "named" | "default"; 7 | export const EXPORT_TYPES: ExportType[] = ["named", "default"]; 8 | 9 | export type QuoteType = "single" | "double"; 10 | export const QUOTE_TYPES: QuoteType[] = ["single", "double"]; 11 | 12 | export interface TypeDefinitionOptions { 13 | classNames: ClassNames; 14 | exportType: ExportType; 15 | exportTypeName?: string; 16 | exportTypeInterface?: string; 17 | quoteType?: QuoteType; 18 | } 19 | 20 | export const exportTypeDefault: ExportType = "named"; 21 | export const exportTypeNameDefault: string = "ClassNames"; 22 | export const exportTypeInterfaceDefault: string = "Styles"; 23 | export const quoteTypeDefault: QuoteType = "single"; 24 | 25 | const classNameToNamedTypeDefinition = (className: ClassName) => 26 | `export const ${className}: string;`; 27 | 28 | const classNameToType = (className: ClassName, quoteType: QuoteType) => { 29 | const quote = quoteType === "single" ? "'" : '"'; 30 | return ` ${quote}${className}${quote}: string;`; 31 | }; 32 | 33 | const isReservedKeyword = (className: ClassName) => 34 | reserved.check(className, "es5", true) || 35 | reserved.check(className, "es6", true); 36 | 37 | const isValidName = (className: ClassName) => { 38 | if (isReservedKeyword(className)) { 39 | alerts.warn( 40 | `[SKIPPING] '${className}' is a reserved keyword (consider renaming or using --exportType default).` 41 | ); 42 | return false; 43 | } else if (/-/.test(className)) { 44 | alerts.warn( 45 | `[SKIPPING] '${className}' contains dashes (consider using 'camelCase' or 'dashes' for --nameFormat or using --exportType default).` 46 | ); 47 | return false; 48 | } 49 | 50 | return true; 51 | }; 52 | 53 | export const classNamesToTypeDefinitions = ( 54 | options: TypeDefinitionOptions 55 | ): string | null => { 56 | if (options.classNames.length) { 57 | let typeDefinitions; 58 | 59 | const { 60 | exportTypeName: ClassNames = exportTypeNameDefault, 61 | exportTypeInterface: Styles = exportTypeInterfaceDefault 62 | } = options; 63 | 64 | switch (options.exportType) { 65 | case "default": 66 | typeDefinitions = `export type ${Styles} = {\n`; 67 | typeDefinitions += options.classNames 68 | .map(className => 69 | classNameToType(className, options.quoteType || quoteTypeDefault) 70 | ) 71 | .join("\n"); 72 | typeDefinitions += "\n}\n\n"; 73 | typeDefinitions += `export type ${ClassNames} = keyof ${Styles};\n\n`; 74 | typeDefinitions += `declare const styles: ${Styles};\n\n`; 75 | typeDefinitions += "export default styles;\n"; 76 | return typeDefinitions; 77 | case "named": 78 | typeDefinitions = options.classNames 79 | .filter(isValidName) 80 | .map(classNameToNamedTypeDefinition); 81 | 82 | // Separate all type definitions be a newline with a trailing newline. 83 | return typeDefinitions.join("\n") + "\n"; 84 | default: 85 | return null; 86 | } 87 | } else { 88 | return null; 89 | } 90 | }; 91 | -------------------------------------------------------------------------------- /lib/typescript/get-type-definition-path.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Given a file path to a LESS file, generate the corresponding type definition 3 | * file path. 4 | * 5 | * @param file the LESS file path 6 | */ 7 | export const getTypeDefinitionPath = (file: string): string => `${file}.d.ts`; 8 | -------------------------------------------------------------------------------- /lib/typescript/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | classNamesToTypeDefinitions, 3 | exportTypeDefault, 4 | exportTypeInterfaceDefault, 5 | exportTypeNameDefault, 6 | quoteTypeDefault, 7 | ExportType, 8 | QuoteType, 9 | EXPORT_TYPES, 10 | QUOTE_TYPES 11 | } from "./class-names-to-type-definition"; 12 | export { logLevelDefault, LogLevel, LOG_LEVELS } from "../core/alerts"; 13 | export { getTypeDefinitionPath } from "./get-type-definition-path"; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@qiniu/typed-less-modules", 3 | "version": "0.1.2", 4 | "description": "TypeScript type definition generator for LESS CSS Modules", 5 | "main": "index.js", 6 | "license": "Apache", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/qiniu/typed-less-modules" 10 | }, 11 | "homepage": "https://github.com/qiniu/typed-less-modules#readme", 12 | "keywords": [ 13 | "less", 14 | "less modules", 15 | "cli", 16 | "typescript", 17 | "type generator", 18 | "less modules" 19 | ], 20 | "scripts": { 21 | "test": "jest", 22 | "tlm": "ts-node ./lib/cli.ts", 23 | "clean": "rm -rf ./dist", 24 | "build": "yarn clean && tsc && chmod +x dist/lib/cli.js", 25 | "prepare": "yarn build", 26 | "check-types": "tsc --noEmit", 27 | "format": "prettier --write '**/*.{js,json,css,md,less,tsx,ts}'", 28 | "check-formatting": "prettier --check '**/*.{js,json,css,md,less,tsx,ts}'", 29 | "codecov": "codecov", 30 | "commit": "commit", 31 | "ci": "yarn check-types && yarn check-formatting && yarn test" 32 | }, 33 | "files": [ 34 | "dist/lib" 35 | ], 36 | "bin": { 37 | "tlm": "./dist/lib/cli.js" 38 | }, 39 | "devDependencies": { 40 | "@commitlint/cli": "^8.2.0", 41 | "@commitlint/config-conventional": "^8.2.0", 42 | "@commitlint/prompt-cli": "^8.2.0", 43 | "@commitlint/travis-cli": "^8.2.0", 44 | "@types/camelcase": "^4.1.0", 45 | "@types/css-modules-loader-core": "^1.1.0", 46 | "@types/glob": "^7.1.1", 47 | "@types/jest": "^24.0.0", 48 | "@types/less": "^3.0.1", 49 | "@types/param-case": "^1.1.2", 50 | "@types/reserved-words": "^0.1.0", 51 | "@types/yargs": "^12.0.8", 52 | "codecov": "^3.7.0", 53 | "husky": "^1.3.1", 54 | "jest": "23.6.0", 55 | "lint-staged": "^8.1.3", 56 | "prettier": "^1.16.4", 57 | "semantic-release": "^15.13.31", 58 | "ts-jest": "^23.10.5", 59 | "ts-node": "^8.0.2", 60 | "typescript": "^3.3.3" 61 | }, 62 | "dependencies": { 63 | "camelcase": "^5.0.0", 64 | "chalk": "^3.0.0", 65 | "chokidar": "^3.3.0", 66 | "css-modules-loader-core": "^1.1.0", 67 | "glob": "^7.1.6", 68 | "less": "^3.11.0", 69 | "param-case": "^3.0.2", 70 | "path": "^0.12.7", 71 | "reserved-words": "^0.1.2", 72 | "slash": "^3.0.0", 73 | "yargs": "^15.0.2" 74 | }, 75 | "husky": { 76 | "hooks": { 77 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS", 78 | "pre-commit": "lint-staged" 79 | } 80 | }, 81 | "lint-staged": { 82 | "*.{js,json,css,md,less,tsx,ts}": [ 83 | "prettier --write", 84 | "git add" 85 | ] 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "skipLibCheck": true, 8 | "baseUrl": ".", 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "outDir": "./dist" 13 | } 14 | } 15 | --------------------------------------------------------------------------------