├── .all-contributorsrc ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE.md └── workflows │ └── build.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── __tests__ ├── __snapshots__ │ └── main.test.ts.snap ├── cli.test.ts ├── configs │ ├── js-default-export │ │ └── typed-scss-modules.config.js │ ├── js-module-exports │ │ └── typed-scss-modules.config.js │ ├── js-named-export │ │ └── typed-scss-modules.config.js │ ├── ts-default-export │ │ └── typed-scss-modules.config.ts │ └── ts-named-export │ │ └── typed-scss-modules.config.ts ├── core │ ├── alerts.test.ts │ ├── generate.test.ts │ ├── list-different.test.ts │ ├── list-different │ │ ├── formatted.scss │ │ ├── formatted.scss.d.ts │ │ └── no-generated.scss │ ├── list-files-and-perform-sanity-check.test.ts │ ├── remove-file.test.ts │ └── write-file.test.ts ├── dart-sass │ ├── dart-sass.test.ts │ ├── use.scss │ └── variables.scss ├── dummy-styles │ ├── alias-prefixes.scss │ ├── alias-prefixes.scss.d.ts │ ├── aliases.scss │ ├── aliases.scss.d.ts │ ├── complex.scss │ ├── complex.scss.d.ts │ ├── composes.scss │ ├── composes.scss.d.ts │ ├── dashes.scss │ ├── dashes.scss.d.ts │ ├── empty.scss │ ├── global-variables.scss │ ├── invalid.scss │ ├── invalid.scss.d.ts │ ├── nested-styles │ │ ├── style.scss │ │ └── style.scss.d.ts │ ├── style.scss │ ├── style.scss.d.ts │ └── typed-scss-modules.config.js ├── helpers │ └── index.ts ├── implementations │ ├── get-default-implementation.test.ts │ └── get-implementation.test.ts ├── load.test.ts ├── main.test.ts ├── prettier │ ├── __snapshots__ │ │ └── prettier.test.ts.snap │ └── prettier.test.ts ├── sass │ ├── file-to-class-names.test.ts │ └── importer.test.ts └── typescript │ ├── class-names-to-type-definitions.test.ts │ └── get-type-definition-path.test.ts ├── babel.config.js ├── commitlint.config.js ├── docs └── typed-scss-modules-example.gif ├── examples ├── basic │ ├── README.md │ ├── core │ │ └── variables.scss │ ├── feature-a │ │ ├── index.ts │ │ ├── style.scss │ │ └── style.scss.d.ts │ └── feature-b │ │ ├── index.ts │ │ ├── style.scss │ │ └── style.scss.d.ts ├── config-file │ ├── README.md │ ├── feature │ │ ├── a.scss │ │ ├── a.scss.d.ts │ │ └── variables.json │ └── typed-scss-modules.config.js ├── default-export │ ├── README.md │ └── feature-a │ │ ├── index.ts │ │ ├── style.scss │ │ └── style.scss.d.ts └── output-folder │ ├── README.md │ ├── __generated__ │ └── examples │ │ └── output-folder │ │ ├── feature-a │ │ └── a.scss.d.ts │ │ ├── feature-b │ │ └── b.scss.d.ts │ │ └── feature-c │ │ ├── c.scss.d.ts │ │ └── nested │ │ └── nested.scss.d.ts │ ├── feature-a │ ├── a.scss │ └── index.ts │ ├── feature-b │ ├── b.scss │ └── index.ts │ ├── feature-c │ ├── c.scss │ ├── index.ts │ └── nested │ │ ├── index.ts │ │ └── nested.scss │ └── typed-scss-modules.config.js ├── jest.config.ts ├── lib ├── cli.ts ├── core │ ├── alerts.ts │ ├── generate.ts │ ├── index.ts │ ├── list-different.ts │ ├── list-files-and-perform-sanity-checks.ts │ ├── remove-file.ts │ ├── types.ts │ ├── watch.ts │ └── write-file.ts ├── implementations │ └── index.ts ├── index.ts ├── load.ts ├── main.ts ├── prettier │ ├── can-resolve.ts │ └── index.ts ├── sass │ ├── file-to-class-names.ts │ ├── importer.ts │ ├── index.ts │ └── source-to-class-names.ts └── typescript │ ├── class-names-to-type-definition.ts │ ├── get-type-definition-path.ts │ └── index.ts ├── package-lock.json ├── package.json └── tsconfig.json /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "badgeTemplate": "[![All Contributors](https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg?style=flat)](#contributors-)", 8 | "contributors": [ 9 | { 10 | "login": "dawnmist", 11 | "name": "Janeene Beeforth", 12 | "avatar_url": "https://avatars3.githubusercontent.com/u/5810277?v=4", 13 | "profile": "https://github.com/dawnmist", 14 | "contributions": [ 15 | "bug", 16 | "code", 17 | "doc" 18 | ] 19 | }, 20 | { 21 | "login": "ericbf", 22 | "name": "Eric Ferreira", 23 | "avatar_url": "https://avatars0.githubusercontent.com/u/2483476?v=4", 24 | "profile": "https://github.com/ericbf", 25 | "contributions": [ 26 | "code", 27 | "doc" 28 | ] 29 | }, 30 | { 31 | "login": "lkarmelo", 32 | "name": "Luis Lopes", 33 | "avatar_url": "https://avatars2.githubusercontent.com/u/20393808?v=4", 34 | "profile": "https://github.com/lkarmelo", 35 | "contributions": [ 36 | "code" 37 | ] 38 | }, 39 | { 40 | "login": "halfnibble", 41 | "name": "Josh Wedekind", 42 | "avatar_url": "https://avatars0.githubusercontent.com/u/5139752?v=4", 43 | "profile": "https://nostalg.io", 44 | "contributions": [ 45 | "code", 46 | "doc", 47 | "test" 48 | ] 49 | }, 50 | { 51 | "login": "peanutbother", 52 | "name": "Jared Gesser", 53 | "avatar_url": "https://avatars3.githubusercontent.com/u/6437182?v=4", 54 | "profile": "https://github.com/peanutbother", 55 | "contributions": [ 56 | "ideas" 57 | ] 58 | }, 59 | { 60 | "login": "raphael-leger", 61 | "name": "Raphaël L", 62 | "avatar_url": "https://avatars1.githubusercontent.com/u/12732777?v=4", 63 | "profile": "https://github.com/raphael-leger", 64 | "contributions": [ 65 | "code", 66 | "ideas" 67 | ] 68 | }, 69 | { 70 | "login": "nperez0111", 71 | "name": "Nick Perez", 72 | "avatar_url": "https://avatars1.githubusercontent.com/u/1852538?v=4", 73 | "profile": "https://NickTheSick.com", 74 | "contributions": [ 75 | "bug", 76 | "code" 77 | ] 78 | }, 79 | { 80 | "login": "deificx", 81 | "name": "Even Alander", 82 | "avatar_url": "https://avatars3.githubusercontent.com/u/1771462?v=4", 83 | "profile": "https://alander.org", 84 | "contributions": [ 85 | "code", 86 | "test", 87 | "ideas" 88 | ] 89 | }, 90 | { 91 | "login": "inkblotty", 92 | "name": "Katie Foster", 93 | "avatar_url": "https://avatars3.githubusercontent.com/u/14206003?v=4", 94 | "profile": "http://inkblotty.github.io", 95 | "contributions": [ 96 | "code", 97 | "test", 98 | "doc" 99 | ] 100 | }, 101 | { 102 | "login": "ccortezaguilera", 103 | "name": "Carlos Aguilera", 104 | "avatar_url": "https://avatars3.githubusercontent.com/u/10718803?v=4", 105 | "profile": "https://github.com/ccortezaguilera", 106 | "contributions": [ 107 | "code" 108 | ] 109 | }, 110 | { 111 | "login": "craigrmccown", 112 | "name": "Craig McCown", 113 | "avatar_url": "https://avatars1.githubusercontent.com/u/2373979?v=4", 114 | "profile": "https://github.com/craigrmccown", 115 | "contributions": [ 116 | "ideas", 117 | "code", 118 | "test", 119 | "doc" 120 | ] 121 | }, 122 | { 123 | "login": "capsuleman", 124 | "name": "Guillaume Vagner", 125 | "avatar_url": "https://avatars.githubusercontent.com/u/34281913?v=4", 126 | "profile": "https://github.com/capsuleman", 127 | "contributions": [ 128 | "code", 129 | "test", 130 | "bug" 131 | ] 132 | }, 133 | { 134 | "login": "srmagura", 135 | "name": "Sam Magura", 136 | "avatar_url": "https://avatars.githubusercontent.com/u/801549?v=4", 137 | "profile": "https://dev.to/srmagura", 138 | "contributions": [ 139 | "code", 140 | "test" 141 | ] 142 | }, 143 | { 144 | "login": "MichaelGregory", 145 | "name": "Mike Gregory", 146 | "avatar_url": "https://avatars.githubusercontent.com/u/1435960?v=4", 147 | "profile": "https://github.com/MichaelGregory", 148 | "contributions": [ 149 | "bug", 150 | "code", 151 | "test" 152 | ] 153 | } 154 | ], 155 | "contributorsPerLine": 7, 156 | "projectName": "typed-scss-modules", 157 | "projectOwner": "skovy", 158 | "repoType": "github", 159 | "repoHost": "https://github.com", 160 | "commitConvention": "angular", 161 | "skipCi": false 162 | } 163 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | root: true, 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:jest-formatting/strict", 8 | "plugin:jest/recommended", 9 | ], 10 | parser: "@typescript-eslint/parser", 11 | plugins: ["@typescript-eslint", "promise", "jest", "jest-formatting"], 12 | ignorePatterns: ["dist/**"], 13 | overrides: [ 14 | { 15 | files: ["*.ts", "*.tsx"], 16 | extends: [ 17 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 18 | ], 19 | parserOptions: { 20 | project: ["./tsconfig.json"], 21 | }, 22 | }, 23 | ], 24 | rules: { 25 | "promise/prefer-await-to-then": "error", 26 | "jest/consistent-test-it": ["error", { fn: "it" }], 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /.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/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: 5 | - "master" 6 | - "alpha" 7 | pull_request: 8 | branches: 9 | - "**" 10 | 11 | jobs: 12 | release: 13 | name: Test and Release 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v3 18 | with: 19 | fetch-depth: 0 20 | - name: Setup Node.js 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: "16" 24 | - name: Install dependencies 25 | run: npm ci 26 | - name: "Unit tests" 27 | run: npm test 28 | - name: "Reset changes from integration unit tests" 29 | run: git reset --hard HEAD 30 | - name: "Build" 31 | run: npm run build 32 | - name: "Type check" 33 | run: npm run check-types 34 | - name: "Code linting check" 35 | run: npm run check-linting 36 | - name: "Code formatting check" 37 | run: npm run check-formatting 38 | - name: "Commit formatting check" 39 | uses: wagoid/commitlint-github-action@v5 40 | - name: Release 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 44 | run: npm run semantic-release 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | dist 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-organize-imports"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Spencer Miskoviak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🎁 typed-scss-modules 2 | 3 | [![npm version](https://img.shields.io/npm/v/typed-scss-modules.svg?style=flat)](https://www.npmjs.com/package/typed-scss-modules) 4 | 5 | Generate TypeScript definitions (`.d.ts`) files for CSS Modules that are written in SCSS (`.scss`). Check out [this post to learn more](https://skovy.dev/generating-typescript-definitions-for-css-modules-using-sass/) about the rationale and inspiration behind this package. 6 | 7 | ![Example](/docs/typed-scss-modules-example.gif) 8 | 9 | For example, given the following SCSS: 10 | 11 | ```scss 12 | @import "variables"; 13 | 14 | .text { 15 | color: $blue; 16 | 17 | &-highlighted { 18 | color: $yellow; 19 | } 20 | } 21 | ``` 22 | 23 | The following type definitions will be generated: 24 | 25 | ```typescript 26 | export declare const text: string; 27 | export declare const textHighlighted: string; 28 | ``` 29 | 30 | ## Basic Usage 31 | 32 | Install and run as a `devDependency`: 33 | 34 | ```bash 35 | yarn add -D typed-scss-modules 36 | yarn typed-scss-modules src 37 | ``` 38 | 39 | Or, install globally: 40 | 41 | ```bash 42 | yarn global add typed-scss-modules 43 | typed-scss-modules src 44 | ``` 45 | 46 | Or, with npm: 47 | 48 | ```bash 49 | npm install -D typed-scss-modules 50 | npx typed-scss-modules src 51 | ``` 52 | 53 | ## CLI Options 54 | 55 | For all possible commands, run `typed-scss-modules --help`. 56 | 57 | The only required argument is the directory where all SCSS files are located. Running `typed-scss-modules src` will search for all files matching `src/**/*.scss`. This can be overridden by providing a [glob](https://github.com/isaacs/node-glob#glob-primer) pattern instead of a directory. For example, `typed-scss-modules src/*.scss` 58 | 59 | ### `--watch` (`-w`) 60 | 61 | - **Type**: `boolean` 62 | - **Default**: `false` 63 | - **Example**: `typed-scss-modules src --watch` 64 | 65 | Watch for files that get added or are changed and generate the corresponding type definitions. 66 | 67 | ### `--ignoreInitial` 68 | 69 | - **Type**: `boolean` 70 | - **Default**: `false` 71 | - **Example**: `typed-scss-modules src --watch --ignoreInitial` 72 | 73 | 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. 74 | 75 | ### `--ignore` 76 | 77 | - **Type**: `string[]` 78 | - **Default**: `[]` 79 | - **Example**: `typed-scss-modules src --watch --ignore "**/secret.scss"` 80 | 81 | A pattern or an array of glob patterns to exclude files that match and avoid generating type definitions. 82 | 83 | ### `--includePaths` (`-i`) 84 | 85 | - **Type**: `string[]` 86 | - **Default**: `[]` 87 | - **Example**: `typed-scss-modules src --includePaths src/core` 88 | 89 | 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. 90 | 91 | ### `--implementation` 92 | 93 | - **Type**: `"node-sass" | "sass"` 94 | - **Default**: If an option is passed, it will always use the provided package implementation. If an option is not passed, it will first check if `node-sass` is installed. If it is, it will be used. Otherwise, it will then check if `sass` is installed. If it is, it will be used. Finally, falling back to `node-sass` if all checks and validations fail. 95 | - **Example**: `typed-scss-modules src --implementation sass` 96 | 97 | ### `--aliases` (`-a`) 98 | 99 | - **Type**: `object` 100 | - **Default**: `{}` 101 | - **Example**: `typed-scss-modules src --aliases.~some-alias src/core/variables` 102 | 103 | An object of aliases to map to their corresponding paths. This example will replace any `@import '~alias'` with `@import 'src/core/variables'`. 104 | 105 | ### `--aliasPrefixes` (`-p`) 106 | 107 | - **Type**: `object` 108 | - **Default**: `{}` 109 | - **Example**: `typed-scss-modules src --aliasPrefixes.~ node_modules/` 110 | 111 | An object of prefix strings to replace with their corresponding paths. This example will replace any `@import '~bootstrap/lib/bootstrap'` with `@import 'node_modules/bootstrap/lib/bootstrap'`. 112 | This matches the common use-case for importing scss files from node_modules when `sass-loader` will be used with `webpack` to compile the project. 113 | 114 | ### `--nameFormat` (`-n`) 115 | 116 | - **Type**: `"all" | "camel" | "kebab" | "param" | "snake" | "dashes" | "none"` 117 | - **Default**: `"camel"` 118 | - **Examples**: 119 | - `typed-scss-modules src --nameFormat camel` 120 | - `typed-scss-modules src --nameFormat kebab --nameFormat dashes --exportType default`. In order to use multiple formatters, you must use `--exportType default`. 121 | 122 | The class naming format to use when converting the classes to type definitions. 123 | 124 | - **all**: makes use of all formatters (except `all` and `none`) and converts all class names to their respective formats, with no duplication. In order to use this option, you must use `--exportType default`. 125 | - **camel**: convert all class names to camel-case, e.g. `App-Logo` => `appLogo`. 126 | - **kebab**/**param**: convert all class names to kebab/param case, e.g. `App-Logo` => `app-logo` (all lower case with '-' separators). 127 | - **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. 128 | - **snake**: convert all class names to lower case with underscores between words. 129 | - **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). 130 | 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. 131 | 132 | ### `--listDifferent` (`-l`) 133 | 134 | - **Type**: `boolean` 135 | - **Default**: `false` 136 | - **Example**: `typed-scss-modules src --listDifferent` 137 | 138 | List any type definition files that are different than those that would be generated. If any are different, exit with a status code `1`. 139 | 140 | ### `--exportType` (`-e`) 141 | 142 | - **Type**: `"named" | "default"` 143 | - **Default**: `"named"` 144 | - **Example**: `typed-scss-modules src --exportType default` 145 | 146 | The export type to use when generating type definitions. 147 | 148 | #### `named` 149 | 150 | Given the following SCSS: 151 | 152 | ```scss 153 | .text { 154 | color: blue; 155 | 156 | &-highlighted { 157 | color: yellow; 158 | } 159 | } 160 | ``` 161 | 162 | The following type definitions will be generated: 163 | 164 | ```typescript 165 | export declare const text: string; 166 | export declare const textHighlighted: string; 167 | ``` 168 | 169 | #### `default` 170 | 171 | Given the following SCSS: 172 | 173 | ```scss 174 | .text { 175 | color: blue; 176 | 177 | &-highlighted { 178 | color: yellow; 179 | } 180 | } 181 | ``` 182 | 183 | The following type definitions will be generated: 184 | 185 | ```typescript 186 | export type Styles = { 187 | text: string; 188 | textHighlighted: string; 189 | }; 190 | 191 | export type ClassNames = keyof Styles; 192 | 193 | declare const styles: Styles; 194 | 195 | export default styles; 196 | ``` 197 | 198 | 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. 199 | 200 | ### `--exportTypeName` 201 | 202 | - **Type**: `string` 203 | - **Default**: `"ClassNames"` 204 | - **Example**: `typed-scss-modules src --exportType default --exportTypeName ClassesType` 205 | 206 | Customize the type name exported in the generated file when `--exportType` is set to `"default"`. 207 | Only default exports are affected by this command. This example will change the export type line to: 208 | 209 | ```typescript 210 | export type ClassesType = keyof Styles; 211 | ``` 212 | 213 | ### `--exportTypeInterface` 214 | 215 | - **Type**: `string` 216 | - **Default**: `"Styles"` 217 | - **Example**: `typed-scss-modules src --exportType default --exportTypeInterface IStyles` 218 | 219 | Customize the interface name exported in the generated file when `--exportType` is set to `"default"`. 220 | Only default exports are affected by this command. This example will change the export interface line to: 221 | 222 | ```typescript 223 | export type IStyles = { 224 | // ... 225 | }; 226 | ``` 227 | 228 | ### `--quoteType` (`-q`) 229 | 230 | - **Type**: `"single" | "double"` 231 | - **Default**: `"single"` 232 | - **Example**: `typed-scss-modules src --exportType default --quoteType double` 233 | 234 | 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 ("). If [Prettier](https://prettier.io) is installed and configured in the project, it will be used and is likely to override the effect of this setting. 235 | 236 | ### `--updateStaleOnly` (`-u`) 237 | 238 | - **Type**: `boolean` 239 | - **Default**: `false` 240 | - **Example**: `typed-scss-modules src --updateStaleOnly` 241 | 242 | Overwrite generated files only if the source file has more recent changes. This can be useful if you want to avoid extraneous file updates, which can cause watcher processes to trigger unnecessarily (e.g. `tsc --watch`). This is done by first checking if the generated file was modified more recently than the source file, and secondly by comparing the existing file contents to the generated file contents. 243 | 244 | Caveat: If a generated type definition file is updated manually, it won't be re-generated until the corresponding scss file is also updated. 245 | 246 | ### `--logLevel` (`-L`) 247 | 248 | - **Type**: `"verbose" | "error" | "info" | "silent"` 249 | - **Default**: `"verbose"` 250 | - **Example**: `typed-scss-modules src --logLevel error` 251 | 252 | Sets verbosity level of console output. 253 | 254 | #### `verbose` 255 | 256 | Print all messages 257 | 258 | #### `error` 259 | 260 | Print only errors 261 | 262 | #### `info` 263 | 264 | Print only some messages 265 | 266 | #### `silent` 267 | 268 | Print nothing 269 | 270 | ### `--banner` 271 | 272 | - **Type**: `string` 273 | - **Default**: `undefined` 274 | - **Example**: `typed-scss-modules src --banner '// This is an example banner\n'` 275 | 276 | Will prepend a string to the top of your output files 277 | 278 | ```typescript 279 | // This is an example banner 280 | export type Styles = { 281 | // ... 282 | }; 283 | ``` 284 | 285 | ### `--outputFolder` (`-o`) 286 | 287 | - **Type**: `string` 288 | - **Default**: _none_ 289 | - **Example**: `typed-scss-modules src --outputFolder __generated__` 290 | 291 | Set a relative folder to output the generated type definitions. Instead of writing the type definitions directly next to each SCSS module (sibling file), it will write to the output folder with the same path. 292 | 293 | It will use the relative path to the SCSS module from where this tool is executed. This same path (including any directories) will be constructed in the output folder. This is important for this to work properly with TypeScript. 294 | 295 | **Important**: for this to work as expected the `tsconfig.json` needs to have [`rootDirs`](https://www.typescriptlang.org/tsconfig#rootDirs) added with the same output folder. This will allow TypeScript to pick up these type definitions and map them to the actual SCSS modules. 296 | 297 | ```json 298 | { 299 | "compilerOptions": { 300 | "rootDirs": [".", "__generated__"] 301 | } 302 | } 303 | ``` 304 | 305 | ### `--additionalData` (`-d`) 306 | 307 | - **Type**: `string` 308 | - **Default**: _none_ 309 | - **Example**: `typed-scss-modules src --additionalData '$global-var: green;'` 310 | 311 | Prepend the provided SCSS code before each file. This is useful for injecting globals into every file, such as adding an import to load global variables for each file. 312 | 313 | ## Config options 314 | 315 | All options above are also supported as a configuration file in the root of the project. The following configuration file names are supported: 316 | 317 | - `typed-scss-modules.config.ts` 318 | - `typed-scss-modules.config.js` 319 | 320 | The file can provide either a named `config` export or a default export. 321 | 322 | ```js 323 | // Example of a named export with some of the options sets. 324 | export const config = { 325 | banner: "// customer banner", 326 | exportType: "default", 327 | exportTypeName: "TheClasses", 328 | logLevel: "error", 329 | }; 330 | 331 | // Example of a default export with some of the options sets. 332 | export default { 333 | banner: "// customer banner", 334 | exportType: "default", 335 | exportTypeName: "TheClasses", 336 | logLevel: "error", 337 | }; 338 | ``` 339 | 340 | > Note: the configuration options are the same as the CLI options without the leading dashes (`--`). Only the full option name is supported (not aliases) in the configuration file. 341 | 342 | CLI options will take precedence over configuration file options. 343 | 344 | In addition to all CLI options, the following are options only available with the configuration file: 345 | 346 | ### `importer` 347 | 348 | - **Type**: `Importer | Importer[]` 349 | - **Default**: _none_ 350 | 351 | Define a [single custom SASS importer or an array of SASS importers](https://github.com/sass/sass/blob/f355f602fc15f55b0a0a795ebe6eb819963e08a5/js-api-doc/legacy/importer.d.ts#L51-L149). This should only be necessary if custom SASS importers are already being used in the build process. This is used internally to implement `aliases` and `aliasPrefixes`. 352 | 353 | Refer to [`lib/sass/importer.ts`](/blob/master/lib/sass/importer.ts) for more details and the `node-sass` and `sass` importer type definitions. 354 | 355 | ### `--allowArbitraryExtensions` 356 | 357 | - **Type**: `boolean` 358 | - **Default**: `false` 359 | - **Example**: `typed-scss-modules src --allowArbitraryExtensions` 360 | 361 | Output filenames that will be compatible with the "arbitrary file extensions" feature that was introduced in TypeScript 5.0. See [the docs](https://www.typescriptlang.org/tsconfig#allowArbitraryExtensions) for more info. 362 | 363 | In essence, the `*.scss.d.ts` extension now becomes `*.d.scss.ts` so that you can import SCSS modules in projects using ESM module resolution. 364 | 365 | ## Examples 366 | 367 | For examples of how this tool can be used and configured, see the `examples` directory: 368 | 369 | - [Basic example](/examples/basic) 370 | - [Default export example](/examples/default-export) 371 | - [Config file (with custom importer) example](/examples/config-file) 372 | 373 | ## Contributors ✨ 374 | 375 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 |
Janeene Beeforth
Janeene Beeforth

🐛 💻 📖
Eric Ferreira
Eric Ferreira

💻 📖
Luis Lopes
Luis Lopes

💻
Josh Wedekind
Josh Wedekind

💻 📖 ⚠️
Jared Gesser
Jared Gesser

🤔
Raphaël L
Raphaël L

💻 🤔
Nick Perez
Nick Perez

🐛 💻
Even Alander
Even Alander

💻 ⚠️ 🤔
Katie Foster
Katie Foster

💻 ⚠️ 📖
Carlos Aguilera
Carlos Aguilera

💻
Craig McCown
Craig McCown

🤔 💻 ⚠️ 📖
Guillaume Vagner
Guillaume Vagner

💻 ⚠️ 🐛
Sam Magura
Sam Magura

💻 ⚠️
Mike Gregory
Mike Gregory

🐛 💻 ⚠️
402 | 403 | 404 | 405 | 406 | 407 | 408 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 409 | 410 | ## Alternatives 411 | 412 | This package was heavily influenced on [typed-css-modules](https://github.com/Quramy/typed-css-modules) which generates TypeScript definitions (`.d.ts`) files for CSS Modules that are written in CSS (`.css`). 413 | 414 | 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). 415 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/main.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`node-sass implementation main outputs the correct files when outputFolder is passed 1`] = ` 4 | [ 5 | { 6 | "contents": "export declare const myCustomClass: string; 7 | export declare const nestedAnother: string; 8 | export declare const nestedClass: string; 9 | export declare const nestedStyles: string; 10 | export declare const number1: string; 11 | export declare const someStyles: string; 12 | export declare const whereSelector: string; 13 | ", 14 | "path": "../__generated__/__tests__/dummy-styles/alias-prefixes.scss.d.ts", 15 | }, 16 | { 17 | "contents": "export declare const myCustomClass: string; 18 | export declare const nestedAnother: string; 19 | export declare const nestedClass: string; 20 | export declare const number1: string; 21 | export declare const someClass: string; 22 | export declare const someStyles: string; 23 | export declare const whereSelector: string; 24 | ", 25 | "path": "../__generated__/__tests__/dummy-styles/aliases.scss.d.ts", 26 | }, 27 | { 28 | "contents": "export declare const nestedAnother: string; 29 | export declare const nestedClass: string; 30 | export declare const number1: string; 31 | export declare const someStyles: string; 32 | export declare const whereSelector: string; 33 | ", 34 | "path": "../__generated__/__tests__/dummy-styles/complex.scss.d.ts", 35 | }, 36 | { 37 | "contents": "export declare const composedClass: string; 38 | ", 39 | "path": "../__generated__/__tests__/dummy-styles/composes.scss.d.ts", 40 | }, 41 | { 42 | "contents": "export declare const app: string; 43 | export declare const appHeader: string; 44 | export declare const logo: string; 45 | ", 46 | "path": "../__generated__/__tests__/dummy-styles/dashes.scss.d.ts", 47 | }, 48 | { 49 | "contents": "export declare const globalStyle: string; 50 | ", 51 | "path": "../__generated__/__tests__/dummy-styles/global-variables.scss.d.ts", 52 | }, 53 | { 54 | "contents": "export declare const randomClass: string; 55 | ", 56 | "path": "../__generated__/__tests__/dummy-styles/invalid.scss.d.ts", 57 | }, 58 | { 59 | "contents": "export declare const nestedStyles: string; 60 | ", 61 | "path": "../__generated__/__tests__/dummy-styles/nested-styles/style.scss.d.ts", 62 | }, 63 | { 64 | "contents": "export declare const someClass: string; 65 | ", 66 | "path": "../__generated__/__tests__/dummy-styles/style.scss.d.ts", 67 | }, 68 | ] 69 | `; 70 | 71 | exports[`node-sass implementation main reads options from the configuration file 1`] = ` 72 | [ 73 | { 74 | "contents": "export type Styles = { 75 | myCustomClass: string; 76 | nestedAnother: string; 77 | nestedClass: string; 78 | nestedStyles: string; 79 | number1: string; 80 | someStyles: string; 81 | whereSelector: string; 82 | }; 83 | 84 | export type ClassNames = keyof Styles; 85 | 86 | declare const styles: Styles; 87 | 88 | export default styles; 89 | ", 90 | "path": "dummy-styles/alias-prefixes.scss.d.ts", 91 | }, 92 | { 93 | "contents": "export type Styles = { 94 | myCustomClass: string; 95 | nestedAnother: string; 96 | nestedClass: string; 97 | number1: string; 98 | someClass: string; 99 | someStyles: string; 100 | whereSelector: string; 101 | }; 102 | 103 | export type ClassNames = keyof Styles; 104 | 105 | declare const styles: Styles; 106 | 107 | export default styles; 108 | ", 109 | "path": "dummy-styles/aliases.scss.d.ts", 110 | }, 111 | { 112 | "contents": "export type Styles = { 113 | nestedAnother: string; 114 | nestedClass: string; 115 | number1: string; 116 | someStyles: string; 117 | whereSelector: string; 118 | }; 119 | 120 | export type ClassNames = keyof Styles; 121 | 122 | declare const styles: Styles; 123 | 124 | export default styles; 125 | ", 126 | "path": "dummy-styles/complex.scss.d.ts", 127 | }, 128 | { 129 | "contents": "export type Styles = { 130 | composedClass: string; 131 | }; 132 | 133 | export type ClassNames = keyof Styles; 134 | 135 | declare const styles: Styles; 136 | 137 | export default styles; 138 | ", 139 | "path": "dummy-styles/composes.scss.d.ts", 140 | }, 141 | { 142 | "contents": "export type Styles = { 143 | app: string; 144 | appHeader: string; 145 | logo: string; 146 | }; 147 | 148 | export type ClassNames = keyof Styles; 149 | 150 | declare const styles: Styles; 151 | 152 | export default styles; 153 | ", 154 | "path": "dummy-styles/dashes.scss.d.ts", 155 | }, 156 | { 157 | "contents": "export type Styles = { 158 | globalStyle: string; 159 | }; 160 | 161 | export type ClassNames = keyof Styles; 162 | 163 | declare const styles: Styles; 164 | 165 | export default styles; 166 | ", 167 | "path": "dummy-styles/global-variables.scss.d.ts", 168 | }, 169 | { 170 | "contents": "export type Styles = { 171 | randomClass: string; 172 | }; 173 | 174 | export type ClassNames = keyof Styles; 175 | 176 | declare const styles: Styles; 177 | 178 | export default styles; 179 | ", 180 | "path": "dummy-styles/invalid.scss.d.ts", 181 | }, 182 | { 183 | "contents": "export type Styles = { 184 | nestedStyles: string; 185 | }; 186 | 187 | export type ClassNames = keyof Styles; 188 | 189 | declare const styles: Styles; 190 | 191 | export default styles; 192 | ", 193 | "path": "dummy-styles/nested-styles/style.scss.d.ts", 194 | }, 195 | { 196 | "contents": "export type Styles = { 197 | someClass: string; 198 | }; 199 | 200 | export type ClassNames = keyof Styles; 201 | 202 | declare const styles: Styles; 203 | 204 | export default styles; 205 | ", 206 | "path": "dummy-styles/style.scss.d.ts", 207 | }, 208 | ] 209 | `; 210 | 211 | exports[`sass implementation main outputs the correct files when outputFolder is passed 1`] = ` 212 | [ 213 | { 214 | "contents": "export declare const myCustomClass: string; 215 | export declare const nestedAnother: string; 216 | export declare const nestedClass: string; 217 | export declare const nestedStyles: string; 218 | export declare const number1: string; 219 | export declare const someStyles: string; 220 | export declare const whereSelector: string; 221 | ", 222 | "path": "../__generated__/__tests__/dummy-styles/alias-prefixes.scss.d.ts", 223 | }, 224 | { 225 | "contents": "export declare const myCustomClass: string; 226 | export declare const nestedAnother: string; 227 | export declare const nestedClass: string; 228 | export declare const number1: string; 229 | export declare const someClass: string; 230 | export declare const someStyles: string; 231 | export declare const whereSelector: string; 232 | ", 233 | "path": "../__generated__/__tests__/dummy-styles/aliases.scss.d.ts", 234 | }, 235 | { 236 | "contents": "export declare const nestedAnother: string; 237 | export declare const nestedClass: string; 238 | export declare const number1: string; 239 | export declare const someStyles: string; 240 | export declare const whereSelector: string; 241 | ", 242 | "path": "../__generated__/__tests__/dummy-styles/complex.scss.d.ts", 243 | }, 244 | { 245 | "contents": "export declare const composedClass: string; 246 | ", 247 | "path": "../__generated__/__tests__/dummy-styles/composes.scss.d.ts", 248 | }, 249 | { 250 | "contents": "export declare const app: string; 251 | export declare const appHeader: string; 252 | export declare const logo: string; 253 | ", 254 | "path": "../__generated__/__tests__/dummy-styles/dashes.scss.d.ts", 255 | }, 256 | { 257 | "contents": "export declare const globalStyle: string; 258 | ", 259 | "path": "../__generated__/__tests__/dummy-styles/global-variables.scss.d.ts", 260 | }, 261 | { 262 | "contents": "export declare const randomClass: string; 263 | ", 264 | "path": "../__generated__/__tests__/dummy-styles/invalid.scss.d.ts", 265 | }, 266 | { 267 | "contents": "export declare const nestedStyles: string; 268 | ", 269 | "path": "../__generated__/__tests__/dummy-styles/nested-styles/style.scss.d.ts", 270 | }, 271 | { 272 | "contents": "export declare const someClass: string; 273 | ", 274 | "path": "../__generated__/__tests__/dummy-styles/style.scss.d.ts", 275 | }, 276 | ] 277 | `; 278 | 279 | exports[`sass implementation main reads options from the configuration file 1`] = ` 280 | [ 281 | { 282 | "contents": "export type Styles = { 283 | myCustomClass: string; 284 | nestedAnother: string; 285 | nestedClass: string; 286 | nestedStyles: string; 287 | number1: string; 288 | someStyles: string; 289 | whereSelector: string; 290 | }; 291 | 292 | export type ClassNames = keyof Styles; 293 | 294 | declare const styles: Styles; 295 | 296 | export default styles; 297 | ", 298 | "path": "dummy-styles/alias-prefixes.scss.d.ts", 299 | }, 300 | { 301 | "contents": "export type Styles = { 302 | myCustomClass: string; 303 | nestedAnother: string; 304 | nestedClass: string; 305 | number1: string; 306 | someClass: string; 307 | someStyles: string; 308 | whereSelector: string; 309 | }; 310 | 311 | export type ClassNames = keyof Styles; 312 | 313 | declare const styles: Styles; 314 | 315 | export default styles; 316 | ", 317 | "path": "dummy-styles/aliases.scss.d.ts", 318 | }, 319 | { 320 | "contents": "export type Styles = { 321 | nestedAnother: string; 322 | nestedClass: string; 323 | number1: string; 324 | someStyles: string; 325 | whereSelector: string; 326 | }; 327 | 328 | export type ClassNames = keyof Styles; 329 | 330 | declare const styles: Styles; 331 | 332 | export default styles; 333 | ", 334 | "path": "dummy-styles/complex.scss.d.ts", 335 | }, 336 | { 337 | "contents": "export type Styles = { 338 | composedClass: string; 339 | }; 340 | 341 | export type ClassNames = keyof Styles; 342 | 343 | declare const styles: Styles; 344 | 345 | export default styles; 346 | ", 347 | "path": "dummy-styles/composes.scss.d.ts", 348 | }, 349 | { 350 | "contents": "export type Styles = { 351 | app: string; 352 | appHeader: string; 353 | logo: string; 354 | }; 355 | 356 | export type ClassNames = keyof Styles; 357 | 358 | declare const styles: Styles; 359 | 360 | export default styles; 361 | ", 362 | "path": "dummy-styles/dashes.scss.d.ts", 363 | }, 364 | { 365 | "contents": "export type Styles = { 366 | globalStyle: string; 367 | }; 368 | 369 | export type ClassNames = keyof Styles; 370 | 371 | declare const styles: Styles; 372 | 373 | export default styles; 374 | ", 375 | "path": "dummy-styles/global-variables.scss.d.ts", 376 | }, 377 | { 378 | "contents": "export type Styles = { 379 | randomClass: string; 380 | }; 381 | 382 | export type ClassNames = keyof Styles; 383 | 384 | declare const styles: Styles; 385 | 386 | export default styles; 387 | ", 388 | "path": "dummy-styles/invalid.scss.d.ts", 389 | }, 390 | { 391 | "contents": "export type Styles = { 392 | nestedStyles: string; 393 | }; 394 | 395 | export type ClassNames = keyof Styles; 396 | 397 | declare const styles: Styles; 398 | 399 | export default styles; 400 | ", 401 | "path": "dummy-styles/nested-styles/style.scss.d.ts", 402 | }, 403 | { 404 | "contents": "export type Styles = { 405 | someClass: string; 406 | }; 407 | 408 | export type ClassNames = keyof Styles; 409 | 410 | declare const styles: Styles; 411 | 412 | export default styles; 413 | ", 414 | "path": "dummy-styles/style.scss.d.ts", 415 | }, 416 | ] 417 | `; 418 | -------------------------------------------------------------------------------- /__tests__/cli.test.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "child_process"; 2 | 3 | describe("cli", () => { 4 | it("should run when no files are found", () => { 5 | const result = execSync("npm run typed-scss-modules 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 | `npm run typed-scss-modules "examples/basic/**/*.scss" -- --includePaths examples/basic/core --aliases.~alias variables --banner '// example banner'` 14 | ).toString(); 15 | 16 | expect(result).toContain("Found 3 files. Generating type definitions..."); 17 | }); 18 | 19 | it("should run the default-export example without errors", () => { 20 | const result = execSync( 21 | `npm run typed-scss-modules "examples/default-export/**/*.scss" -- --exportType default --nameFormat kebab --banner '// example banner'` 22 | ).toString(); 23 | 24 | expect(result).toContain("Found 1 file. Generating type definitions..."); 25 | }); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /__tests__/configs/js-default-export/typed-scss-modules.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | banner: "// js-default-export", 3 | }; 4 | -------------------------------------------------------------------------------- /__tests__/configs/js-module-exports/typed-scss-modules.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | banner: "// js-module-exports", 4 | }; 5 | -------------------------------------------------------------------------------- /__tests__/configs/js-named-export/typed-scss-modules.config.js: -------------------------------------------------------------------------------- 1 | export const config = { 2 | banner: "// js-named-export", 3 | }; 4 | -------------------------------------------------------------------------------- /__tests__/configs/ts-default-export/typed-scss-modules.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | banner: "// ts-default-export", 3 | }; 4 | -------------------------------------------------------------------------------- /__tests__/configs/ts-named-export/typed-scss-modules.config.ts: -------------------------------------------------------------------------------- 1 | export const config = { 2 | banner: "// ts-named-export", 3 | }; 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 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 16 | const EXPECTED = expect.stringContaining(TEST_ALERT_MSG); 17 | 18 | it("should print all messages with verbose log level", () => { 19 | setAlertsLogLevel("verbose"); 20 | 21 | alerts.error(TEST_ALERT_MSG); 22 | 23 | expect(console.log).toHaveBeenLastCalledWith(EXPECTED); 24 | //make sure each alert only calls console.log once 25 | expect(console.log).toHaveBeenCalledTimes(1); 26 | 27 | alerts.warn(TEST_ALERT_MSG); 28 | 29 | expect(console.log).toHaveBeenLastCalledWith(EXPECTED); 30 | expect(console.log).toHaveBeenCalledTimes(2); 31 | 32 | alerts.notice(TEST_ALERT_MSG); 33 | 34 | expect(console.log).toHaveBeenLastCalledWith(EXPECTED); 35 | expect(console.log).toHaveBeenCalledTimes(3); 36 | 37 | alerts.info(TEST_ALERT_MSG); 38 | 39 | expect(console.log).toHaveBeenLastCalledWith(EXPECTED); 40 | expect(console.log).toHaveBeenCalledTimes(4); 41 | 42 | alerts.success(TEST_ALERT_MSG); 43 | 44 | expect(console.log).toHaveBeenLastCalledWith(EXPECTED); 45 | expect(console.log).toHaveBeenCalledTimes(5); 46 | }); 47 | 48 | it("should only print error messages with error log level", () => { 49 | setAlertsLogLevel("error"); 50 | 51 | alerts.error(TEST_ALERT_MSG); 52 | 53 | expect(console.log).toHaveBeenLastCalledWith(EXPECTED); 54 | expect(console.log).toHaveBeenCalledTimes(1); 55 | 56 | alerts.warn(TEST_ALERT_MSG); 57 | alerts.notice(TEST_ALERT_MSG); 58 | alerts.info(TEST_ALERT_MSG); 59 | alerts.success(TEST_ALERT_MSG); 60 | 61 | //shouldn't change 62 | expect(console.log).toHaveBeenCalledTimes(1); 63 | }); 64 | 65 | it("should print all but warning messages with info log level", () => { 66 | setAlertsLogLevel("info"); 67 | 68 | alerts.error(TEST_ALERT_MSG); 69 | 70 | expect(console.log).toHaveBeenLastCalledWith(EXPECTED); 71 | expect(console.log).toHaveBeenCalledTimes(1); 72 | 73 | alerts.notice(TEST_ALERT_MSG); 74 | 75 | expect(console.log).toHaveBeenLastCalledWith(EXPECTED); 76 | expect(console.log).toHaveBeenCalledTimes(2); 77 | 78 | alerts.info(TEST_ALERT_MSG); 79 | 80 | expect(console.log).toHaveBeenLastCalledWith(EXPECTED); 81 | expect(console.log).toHaveBeenCalledTimes(3); 82 | 83 | alerts.success(TEST_ALERT_MSG); 84 | 85 | expect(console.log).toHaveBeenLastCalledWith(EXPECTED); 86 | expect(console.log).toHaveBeenCalledTimes(4); 87 | 88 | alerts.warn(TEST_ALERT_MSG); 89 | 90 | expect(console.log).toHaveBeenCalledTimes(4); 91 | }); 92 | 93 | it("should print no messages with silent log level", () => { 94 | setAlertsLogLevel("silent"); 95 | 96 | alerts.error(TEST_ALERT_MSG); 97 | alerts.warn(TEST_ALERT_MSG); 98 | alerts.notice(TEST_ALERT_MSG); 99 | alerts.info(TEST_ALERT_MSG); 100 | alerts.success(TEST_ALERT_MSG); 101 | 102 | expect(console.log).not.toHaveBeenCalled(); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /__tests__/core/generate.test.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { generate } from "../../lib/core"; 3 | import { describeAllImplementations } from "../helpers"; 4 | 5 | describeAllImplementations((implementation) => { 6 | describe("generate", () => { 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 | it("generates types for all files matching the pattern", async () => { 14 | const pattern = `${__dirname}/../dummy-styles/**/*.scss`; 15 | 16 | await generate(pattern, { 17 | banner: "", 18 | watch: false, 19 | ignoreInitial: false, 20 | exportType: "named", 21 | exportTypeName: "ClassNames", 22 | exportTypeInterface: "Styles", 23 | listDifferent: false, 24 | ignore: [], 25 | implementation, 26 | quoteType: "single", 27 | updateStaleOnly: false, 28 | logLevel: "verbose", 29 | outputFolder: null, 30 | allowArbitraryExtensions: false, 31 | }); 32 | 33 | expect(fs.writeFileSync).toHaveBeenCalledTimes(6); 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /__tests__/core/list-different.test.ts: -------------------------------------------------------------------------------- 1 | import { listDifferent } from "../../lib/core"; 2 | import { describeAllImplementations } from "../helpers"; 3 | 4 | describeAllImplementations((implementation) => { 5 | describe("listDifferent", () => { 6 | let exit: jest.SpyInstance; 7 | 8 | beforeEach(() => { 9 | console.log = jest.fn(); 10 | exit = jest.spyOn(process, "exit").mockImplementation(); 11 | }); 12 | 13 | afterEach(() => { 14 | exit.mockRestore(); 15 | }); 16 | 17 | it("logs invalid type definitions and exits with 1", async () => { 18 | const pattern = `${__dirname}/../**/*.scss`; 19 | 20 | await listDifferent(pattern, { 21 | banner: "", 22 | watch: false, 23 | ignoreInitial: false, 24 | exportType: "named", 25 | exportTypeName: "ClassNames", 26 | exportTypeInterface: "Styles", 27 | listDifferent: true, 28 | aliases: { 29 | "~fancy-import": "complex", 30 | "~another": "style", 31 | }, 32 | aliasPrefixes: { 33 | "~": "nested-styles/", 34 | }, 35 | ignore: [], 36 | implementation, 37 | quoteType: "single", 38 | updateStaleOnly: false, 39 | logLevel: "verbose", 40 | outputFolder: null, 41 | allowArbitraryExtensions: false, 42 | }); 43 | 44 | expect(exit).toHaveBeenCalledWith(1); 45 | expect(console.log).toHaveBeenCalledWith( 46 | expect.stringContaining(`[INVALID TYPES] Check type definitions for`) 47 | ); 48 | expect(console.log).toHaveBeenCalledWith( 49 | expect.stringContaining(`invalid.scss`) 50 | ); 51 | }); 52 | 53 | it("logs nothing and does not exit when formatted using Prettier", async () => { 54 | const pattern = `${__dirname}/list-different/formatted.scss`; 55 | 56 | await listDifferent(pattern, { 57 | banner: "", 58 | watch: false, 59 | ignoreInitial: false, 60 | exportType: "default", 61 | exportTypeName: "ClassNames", 62 | exportTypeInterface: "Styles", 63 | listDifferent: true, 64 | ignore: [], 65 | implementation, 66 | quoteType: "single", 67 | updateStaleOnly: false, 68 | logLevel: "verbose", 69 | nameFormat: ["kebab"], 70 | outputFolder: null, 71 | allowArbitraryExtensions: false, 72 | }); 73 | 74 | expect(console.log).toHaveBeenCalledTimes(1); 75 | expect(console.log).toHaveBeenCalledWith( 76 | expect.stringContaining(`Only 1 file found for`) 77 | ); 78 | expect(exit).not.toHaveBeenCalled(); 79 | }); 80 | 81 | it("logs nothing and does not exit if all files are valid", async () => { 82 | const pattern = `${__dirname}/../dummy-styles/**/style.scss`; 83 | 84 | await listDifferent(pattern, { 85 | banner: "", 86 | watch: false, 87 | ignoreInitial: false, 88 | exportType: "named", 89 | exportTypeName: "ClassNames", 90 | exportTypeInterface: "Styles", 91 | listDifferent: true, 92 | ignore: [], 93 | implementation, 94 | quoteType: "single", 95 | updateStaleOnly: false, 96 | logLevel: "verbose", 97 | outputFolder: null, 98 | allowArbitraryExtensions: false, 99 | }); 100 | 101 | expect(exit).not.toHaveBeenCalled(); 102 | expect(console.log).not.toHaveBeenCalled(); 103 | }); 104 | 105 | it("logs not generated type file and exits with 1", async () => { 106 | const pattern = `${__dirname}/list-different/no-generated.scss`; 107 | 108 | await listDifferent(pattern, { 109 | banner: "", 110 | watch: false, 111 | ignoreInitial: false, 112 | exportType: "named", 113 | exportTypeName: "ClassNames", 114 | exportTypeInterface: "Styles", 115 | listDifferent: true, 116 | ignore: [], 117 | implementation, 118 | quoteType: "single", 119 | updateStaleOnly: false, 120 | logLevel: "verbose", 121 | outputFolder: null, 122 | allowArbitraryExtensions: false, 123 | }); 124 | 125 | expect(exit).toHaveBeenCalledWith(1); 126 | expect(console.log).toHaveBeenCalledWith( 127 | expect.stringContaining( 128 | `[INVALID TYPES] Type file needs to be generated for` 129 | ) 130 | ); 131 | expect(console.log).toHaveBeenCalledWith( 132 | expect.stringContaining(`no-generated.scss`) 133 | ); 134 | }); 135 | 136 | it("ignores ignored files", async () => { 137 | const pattern = `${__dirname}/list-different/no-generated.scss`; 138 | 139 | await listDifferent(pattern, { 140 | banner: "", 141 | watch: false, 142 | ignoreInitial: false, 143 | exportType: "named", 144 | exportTypeName: "ClassNames", 145 | exportTypeInterface: "Styles", 146 | listDifferent: true, 147 | ignore: ["**/no-generated.scss"], 148 | implementation, 149 | quoteType: "single", 150 | updateStaleOnly: false, 151 | logLevel: "verbose", 152 | outputFolder: null, 153 | allowArbitraryExtensions: false, 154 | }); 155 | 156 | expect(exit).not.toHaveBeenCalled(); 157 | expect(console.log).toHaveBeenCalledTimes(1); 158 | expect(console.log).toHaveBeenCalledWith( 159 | expect.stringContaining(`No files found`) 160 | ); 161 | }); 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /__tests__/core/list-different/formatted.scss: -------------------------------------------------------------------------------- 1 | .i { 2 | color: orange; 3 | 4 | &-am { 5 | &-kebab { 6 | &-cased { 7 | color: red; 8 | } 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /__tests__/core/list-different/formatted.scss.d.ts: -------------------------------------------------------------------------------- 1 | export type Styles = { 2 | i: string; 3 | "i-am-kebab-cased": string; 4 | }; 5 | 6 | export type ClassNames = keyof Styles; 7 | 8 | declare const styles: Styles; 9 | 10 | export default styles; 11 | -------------------------------------------------------------------------------- /__tests__/core/list-different/no-generated.scss: -------------------------------------------------------------------------------- 1 | .no-generated { 2 | color: blue; 3 | } 4 | -------------------------------------------------------------------------------- /__tests__/core/list-files-and-perform-sanity-check.test.ts: -------------------------------------------------------------------------------- 1 | import { ConfigOptions } from "../../lib/core"; 2 | import { listFilesAndPerformSanityChecks } from "../../lib/core/list-files-and-perform-sanity-checks"; 3 | 4 | const options: ConfigOptions = { 5 | banner: "", 6 | watch: false, 7 | ignoreInitial: false, 8 | exportType: "named", 9 | exportTypeName: "ClassNames", 10 | exportTypeInterface: "Styles", 11 | listDifferent: true, 12 | ignore: [], 13 | implementation: "sass", 14 | quoteType: "single", 15 | updateStaleOnly: false, 16 | logLevel: "verbose", 17 | outputFolder: null, 18 | allowArbitraryExtensions: false, 19 | }; 20 | 21 | describe("listAllFilesAndPerformSanityCheck", () => { 22 | beforeEach(() => { 23 | console.log = jest.fn(); 24 | }); 25 | 26 | it("prints a warning if the pattern matches 0 files", () => { 27 | const pattern = `${__dirname}/list-different/test.txt`; 28 | 29 | listFilesAndPerformSanityChecks(pattern, options); 30 | 31 | expect(console.log).toHaveBeenCalledWith( 32 | expect.stringContaining("No files found.") 33 | ); 34 | }); 35 | 36 | it("prints a warning if the pattern matches 1 file", () => { 37 | const pattern = `${__dirname}/list-different/formatted.scss`; 38 | 39 | listFilesAndPerformSanityChecks(pattern, options); 40 | 41 | expect(console.log).toHaveBeenCalledWith( 42 | expect.stringContaining("Only 1 file found for") 43 | ); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /__tests__/core/remove-file.test.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import { alerts } from "../../lib/core/alerts"; 4 | import { removeSCSSTypeDefinitionFile } from "../../lib/core/remove-file"; 5 | import { DEFAULT_OPTIONS } from "../../lib/load"; 6 | 7 | describe("removeFile", () => { 8 | const originalTestFile = path.resolve(__dirname, "..", "removable.scss"); 9 | const existingFile = path.resolve(__dirname, "..", "style.scss"); 10 | const existingTypes = path.join( 11 | process.cwd(), 12 | "__tests__/removable.scss.d.ts" 13 | ); 14 | const outputFolderExistingTypes = path.resolve( 15 | process.cwd(), 16 | "__generated__/__tests__/removable.scss.d.ts" 17 | ); 18 | 19 | let existsSpy: jest.SpyInstance; 20 | let unlinkSpy: jest.SpyInstance; 21 | let alertsSpy: jest.SpyInstance; 22 | 23 | beforeEach(() => { 24 | existsSpy = jest 25 | .spyOn(fs, "existsSync") 26 | .mockImplementation( 27 | (path) => 28 | path === existingTypes || 29 | path === existingFile || 30 | path === outputFolderExistingTypes 31 | ); 32 | 33 | unlinkSpy = jest.spyOn(fs, "unlinkSync").mockImplementation(); 34 | 35 | alertsSpy = jest.spyOn(alerts, "success").mockImplementation(); 36 | }); 37 | 38 | afterEach(() => { 39 | jest.clearAllMocks(); 40 | }); 41 | 42 | it("does nothing if types file doesn't exist", () => { 43 | const nonExistingFile = path.resolve(__dirname, "..", "deleted.scss"); 44 | const nonExistingTypes = path.join( 45 | process.cwd(), 46 | "__tests__/deleted.scss.d.ts" 47 | ); 48 | 49 | removeSCSSTypeDefinitionFile(nonExistingFile, DEFAULT_OPTIONS); 50 | 51 | expect(existsSpy).toHaveBeenCalledWith( 52 | expect.stringMatching(nonExistingFile) 53 | ); 54 | expect(existsSpy).toHaveBeenCalledWith( 55 | expect.stringMatching(nonExistingTypes) 56 | ); 57 | expect(unlinkSpy).not.toHaveBeenCalled(); 58 | expect(alertsSpy).not.toHaveBeenCalled(); 59 | }); 60 | 61 | it("removes *.scss.d.ts types file for *.scss", () => { 62 | removeSCSSTypeDefinitionFile(originalTestFile, DEFAULT_OPTIONS); 63 | 64 | expect(existsSpy).toHaveBeenCalledWith( 65 | expect.stringMatching(existingTypes) 66 | ); 67 | expect(unlinkSpy).toHaveBeenCalled(); 68 | expect(unlinkSpy).toHaveBeenCalledWith( 69 | expect.stringMatching(existingTypes) 70 | ); 71 | expect(alertsSpy).toHaveBeenCalled(); 72 | }); 73 | 74 | describe("when outputFolder is passed", () => { 75 | it("removes the correct files", () => { 76 | removeSCSSTypeDefinitionFile(originalTestFile, { 77 | ...DEFAULT_OPTIONS, 78 | outputFolder: "__generated__", 79 | }); 80 | 81 | expect(existsSpy).toHaveBeenCalledWith( 82 | expect.stringMatching(outputFolderExistingTypes) 83 | ); 84 | expect(unlinkSpy).toHaveBeenCalled(); 85 | expect(unlinkSpy).toHaveBeenCalledWith( 86 | expect.stringMatching(outputFolderExistingTypes) 87 | ); 88 | expect(alertsSpy).toHaveBeenCalled(); 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /__tests__/core/write-file.test.ts: -------------------------------------------------------------------------------- 1 | import fs, { PathOrFileDescriptor } from "fs"; 2 | import path from "path"; 3 | import { writeFile } from "../../lib/core"; 4 | import { describeAllImplementations } from "../helpers"; 5 | 6 | describeAllImplementations((implementation) => { 7 | describe("writeFile", () => { 8 | beforeEach(() => { 9 | // Only mock the write, so the example files can still be read. 10 | jest.spyOn(fs, "writeFileSync").mockImplementation(); 11 | 12 | // Avoid creating new directories while running tests. 13 | jest.spyOn(fs, "mkdirSync").mockImplementation(); 14 | 15 | // Test removing existing types. 16 | jest.spyOn(fs, "unlinkSync").mockImplementation(); 17 | 18 | console.log = jest.fn(); 19 | }); 20 | 21 | it("writes the corresponding type definitions for a file and logs", async () => { 22 | const testFile = path.resolve(__dirname, "..", "dummy-styles/style.scss"); 23 | 24 | await writeFile(testFile, { 25 | banner: "", 26 | watch: false, 27 | ignoreInitial: false, 28 | exportType: "named", 29 | exportTypeName: "ClassNames", 30 | exportTypeInterface: "Styles", 31 | listDifferent: false, 32 | ignore: [], 33 | implementation, 34 | quoteType: "single", 35 | updateStaleOnly: false, 36 | logLevel: "verbose", 37 | outputFolder: null, 38 | allowArbitraryExtensions: false, 39 | }); 40 | 41 | const expectedPath = path.join( 42 | process.cwd(), 43 | "__tests__/dummy-styles/style.scss.d.ts" 44 | ); 45 | 46 | expect(fs.writeFileSync).toHaveBeenCalledWith( 47 | expectedPath, 48 | "export declare const someClass: string;\n" 49 | ); 50 | expect(console.log).toHaveBeenCalledWith( 51 | expect.stringContaining(`[GENERATED TYPES] ${expectedPath}`) 52 | ); 53 | }); 54 | 55 | it("writes the corresponding type definitions for a file and logs when allowArbitraryExtensions is set", async () => { 56 | const testFile = path.resolve(__dirname, "..", "dummy-styles/style.scss"); 57 | 58 | await writeFile(testFile, { 59 | banner: "", 60 | watch: false, 61 | ignoreInitial: false, 62 | exportType: "named", 63 | exportTypeName: "ClassNames", 64 | exportTypeInterface: "Styles", 65 | listDifferent: false, 66 | ignore: [], 67 | implementation, 68 | quoteType: "single", 69 | updateStaleOnly: false, 70 | logLevel: "verbose", 71 | outputFolder: null, 72 | allowArbitraryExtensions: true, 73 | }); 74 | 75 | const expectedPath = path.join( 76 | process.cwd(), 77 | "__tests__/dummy-styles/style.d.scss.ts" 78 | ); 79 | 80 | expect(fs.writeFileSync).toHaveBeenCalledWith( 81 | expectedPath, 82 | "export declare const someClass: string;\n" 83 | ); 84 | expect(console.log).toHaveBeenCalledWith( 85 | expect.stringContaining(`[GENERATED TYPES] ${expectedPath}`) 86 | ); 87 | }); 88 | 89 | it("skips files with no classes", async () => { 90 | const testFile = path.resolve(__dirname, "..", "dummy-styles/empty.scss"); 91 | 92 | await writeFile(testFile, { 93 | banner: "", 94 | watch: false, 95 | ignoreInitial: false, 96 | exportType: "named", 97 | exportTypeName: "ClassNames", 98 | exportTypeInterface: "Styles", 99 | listDifferent: false, 100 | ignore: [], 101 | implementation, 102 | quoteType: "single", 103 | updateStaleOnly: false, 104 | logLevel: "verbose", 105 | outputFolder: null, 106 | allowArbitraryExtensions: false, 107 | }); 108 | 109 | expect(fs.writeFileSync).not.toHaveBeenCalled(); 110 | expect(console.log).toHaveBeenCalledWith( 111 | expect.stringContaining(`[NO GENERATED TYPES] ${testFile}`) 112 | ); 113 | }); 114 | 115 | describe("when a file already exists with type definitions", () => { 116 | const testFile = path.resolve(__dirname, "..", "dummy-styles/empty.scss"); 117 | const existingTypes = path.join( 118 | process.cwd(), 119 | "__tests__/dummy-styles/empty.scss.d.ts" 120 | ); 121 | const originalExistsSync = fs.existsSync; 122 | 123 | beforeEach(() => { 124 | jest 125 | .spyOn(fs, "existsSync") 126 | .mockImplementation((p) => 127 | p === existingTypes ? true : originalExistsSync(p) 128 | ); 129 | }); 130 | 131 | afterEach(() => { 132 | (fs.existsSync as jest.Mock).mockRestore(); 133 | }); 134 | 135 | it("removes existing type definitions if no classes are found", async () => { 136 | await writeFile(testFile, { 137 | banner: "", 138 | watch: false, 139 | ignoreInitial: false, 140 | exportType: "named", 141 | exportTypeName: "ClassNames", 142 | exportTypeInterface: "Styles", 143 | listDifferent: false, 144 | ignore: [], 145 | implementation, 146 | quoteType: "single", 147 | updateStaleOnly: false, 148 | logLevel: "verbose", 149 | outputFolder: null, 150 | allowArbitraryExtensions: false, 151 | }); 152 | 153 | expect(fs.unlinkSync).toHaveBeenCalledWith(existingTypes); 154 | expect(console.log).toHaveBeenCalledWith( 155 | expect.stringContaining(`[REMOVED] ${existingTypes}`) 156 | ); 157 | }); 158 | }); 159 | 160 | describe("when outputFolder is passed", () => { 161 | it("should write to the correct path", async () => { 162 | const testFile = path.resolve( 163 | __dirname, 164 | "..", 165 | "dummy-styles/style.scss" 166 | ); 167 | 168 | await writeFile(testFile, { 169 | banner: "", 170 | watch: false, 171 | ignoreInitial: false, 172 | exportType: "named", 173 | exportTypeName: "ClassNames", 174 | exportTypeInterface: "Styles", 175 | listDifferent: false, 176 | ignore: [], 177 | implementation, 178 | quoteType: "single", 179 | updateStaleOnly: false, 180 | logLevel: "verbose", 181 | outputFolder: "__generated__", 182 | allowArbitraryExtensions: false, 183 | }); 184 | 185 | const expectedPath = path.join( 186 | process.cwd(), 187 | "__generated__/__tests__/dummy-styles/style.scss.d.ts" 188 | ); 189 | 190 | expect(fs.writeFileSync).toHaveBeenCalledWith( 191 | expectedPath, 192 | "export declare const someClass: string;\n" 193 | ); 194 | expect(console.log).toHaveBeenCalledWith( 195 | expect.stringContaining(`[GENERATED TYPES] ${expectedPath}`) 196 | ); 197 | }); 198 | }); 199 | 200 | describe("when --updateStaleOnly is passed", () => { 201 | const originalReadFileSync = fs.readFileSync; 202 | const testFile = path.resolve(__dirname, "..", "dummy-styles/style.scss"); 203 | const expectedPath = path.join( 204 | process.cwd(), 205 | "__tests__/dummy-styles/style.scss.d.ts" 206 | ); 207 | 208 | beforeEach(() => { 209 | jest.spyOn(fs, "statSync"); 210 | jest.spyOn(fs, "existsSync"); 211 | jest.spyOn(fs, "readFileSync"); 212 | (fs.existsSync as jest.Mock).mockImplementation(() => true); 213 | }); 214 | 215 | afterEach(() => { 216 | (fs.statSync as jest.Mock).mockRestore(); 217 | (fs.existsSync as jest.Mock).mockRestore(); 218 | (fs.readFileSync as jest.Mock).mockRestore(); 219 | }); 220 | 221 | it("skips stale files", async () => { 222 | (fs.statSync as jest.Mock).mockImplementation((p) => ({ 223 | mtime: 224 | p === expectedPath ? new Date(2020, 0, 2) : new Date(2020, 0, 1), 225 | })); 226 | 227 | await writeFile(testFile, { 228 | banner: "", 229 | watch: false, 230 | ignoreInitial: false, 231 | exportType: "named", 232 | exportTypeName: "ClassNames", 233 | exportTypeInterface: "Styles", 234 | listDifferent: false, 235 | ignore: [], 236 | implementation, 237 | quoteType: "single", 238 | updateStaleOnly: true, 239 | logLevel: "verbose", 240 | outputFolder: null, 241 | allowArbitraryExtensions: false, 242 | }); 243 | 244 | expect(fs.writeFileSync).not.toHaveBeenCalled(); 245 | }); 246 | 247 | it("updates files that aren't stale", async () => { 248 | (fs.statSync as jest.Mock).mockImplementation( 249 | () => new Date(2020, 0, 1) 250 | ); 251 | 252 | // Mock outdated file contents. 253 | (fs.readFileSync as jest.Mock).mockImplementation( 254 | ( 255 | p: PathOrFileDescriptor, 256 | opts?: { 257 | encoding?: null | undefined; 258 | flag?: string | undefined; 259 | } | null 260 | ) => (p === expectedPath ? `` : originalReadFileSync(p, opts)) 261 | ); 262 | 263 | await writeFile(testFile, { 264 | banner: "", 265 | watch: false, 266 | ignoreInitial: false, 267 | exportType: "named", 268 | exportTypeName: "ClassNames", 269 | exportTypeInterface: "Styles", 270 | listDifferent: false, 271 | ignore: [], 272 | implementation, 273 | quoteType: "single", 274 | updateStaleOnly: true, 275 | logLevel: "verbose", 276 | outputFolder: null, 277 | allowArbitraryExtensions: false, 278 | }); 279 | 280 | expect(fs.writeFileSync).toHaveBeenCalled(); 281 | }); 282 | 283 | it("skips files that aren't stale but type definition contents haven't changed", async () => { 284 | (fs.statSync as jest.Mock).mockImplementation( 285 | () => new Date(2020, 0, 1) 286 | ); 287 | 288 | await writeFile(testFile, { 289 | banner: "", 290 | watch: false, 291 | ignoreInitial: false, 292 | exportType: "named", 293 | exportTypeName: "ClassNames", 294 | exportTypeInterface: "Styles", 295 | listDifferent: false, 296 | ignore: [], 297 | implementation, 298 | quoteType: "single", 299 | updateStaleOnly: true, 300 | logLevel: "verbose", 301 | outputFolder: null, 302 | allowArbitraryExtensions: false, 303 | }); 304 | 305 | expect(fs.writeFileSync).not.toHaveBeenCalled(); 306 | }); 307 | 308 | it("doesn't attempt to access a non-existent file", async () => { 309 | (fs.existsSync as jest.Mock).mockImplementation(() => false); 310 | 311 | await writeFile(testFile, { 312 | banner: "", 313 | watch: false, 314 | ignoreInitial: false, 315 | exportType: "named", 316 | exportTypeName: "ClassNames", 317 | exportTypeInterface: "Styles", 318 | listDifferent: false, 319 | ignore: [], 320 | implementation, 321 | quoteType: "single", 322 | updateStaleOnly: true, 323 | logLevel: "verbose", 324 | outputFolder: null, 325 | allowArbitraryExtensions: false, 326 | }); 327 | 328 | expect(fs.statSync).not.toHaveBeenCalledWith(testFile); 329 | }); 330 | }); 331 | }); 332 | }); 333 | -------------------------------------------------------------------------------- /__tests__/dart-sass/dart-sass.test.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import slash from "slash"; 3 | import { alerts } from "../../lib/core"; 4 | import { main } from "../../lib/main"; 5 | 6 | describe("dart-sass", () => { 7 | let writeFileSyncSpy: jest.SpyInstance; 8 | 9 | beforeEach(() => { 10 | // Only mock the writes, so the example files can still be read. 11 | writeFileSyncSpy = jest.spyOn(fs, "writeFileSync").mockImplementation(); 12 | 13 | // Avoid creating directories while running tests. 14 | jest.spyOn(fs, "mkdirSync").mockImplementation(); 15 | 16 | // Avoid console logs showing up. 17 | jest.spyOn(console, "log").mockImplementation(); 18 | 19 | jest.spyOn(alerts, "error").mockImplementation(); 20 | }); 21 | 22 | afterEach(() => { 23 | writeFileSyncSpy.mockReset(); 24 | }); 25 | 26 | it("@use support", async () => { 27 | const pattern = `${__dirname}`; 28 | 29 | await main(pattern, { 30 | banner: "", 31 | watch: false, 32 | ignoreInitial: false, 33 | exportType: "named", 34 | exportTypeName: "ClassNames", 35 | exportTypeInterface: "Styles", 36 | listDifferent: false, 37 | ignore: [], 38 | implementation: "sass", 39 | quoteType: "single", 40 | updateStaleOnly: false, 41 | logLevel: "verbose", 42 | additionalData: "$global-red: red;", 43 | aliases: { 44 | "~fancy-import": "complex", 45 | "~another": "style", 46 | }, 47 | aliasPrefixes: { 48 | "~": "nested-styles/", 49 | }, 50 | }); 51 | 52 | expect(alerts.error).not.toHaveBeenCalled(); 53 | expect(fs.writeFileSync).toHaveBeenCalledTimes(1); 54 | 55 | const expectedDirname = slash(__dirname); 56 | 57 | expect(fs.writeFileSync).toHaveBeenCalledWith( 58 | `${expectedDirname}/use.scss.d.ts`, 59 | "export declare const foo: string;\n" 60 | ); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /__tests__/dart-sass/use.scss: -------------------------------------------------------------------------------- 1 | @use "variables"; 2 | 3 | .foo { 4 | color: variables.$red; 5 | } 6 | -------------------------------------------------------------------------------- /__tests__/dart-sass/variables.scss: -------------------------------------------------------------------------------- 1 | $red: red; 2 | -------------------------------------------------------------------------------- /__tests__/dummy-styles/alias-prefixes.scss: -------------------------------------------------------------------------------- 1 | @import "~fancy-import"; 2 | @import "~style.scss"; 3 | 4 | .my-custom-class { 5 | color: green; 6 | } 7 | -------------------------------------------------------------------------------- /__tests__/dummy-styles/alias-prefixes.scss.d.ts: -------------------------------------------------------------------------------- 1 | export declare const someStyles: string; 2 | export declare const nestedClass: string; 3 | export declare const nestedAnother: string; 4 | export declare const nestedStyles: string; 5 | export declare const myCustomClass: string; 6 | -------------------------------------------------------------------------------- /__tests__/dummy-styles/aliases.scss: -------------------------------------------------------------------------------- 1 | @import "~fancy-import"; 2 | @import "~another"; 3 | 4 | .my-custom-class { 5 | color: green; 6 | } 7 | -------------------------------------------------------------------------------- /__tests__/dummy-styles/aliases.scss.d.ts: -------------------------------------------------------------------------------- 1 | export declare const someStyles: string; 2 | export declare const nestedClass: string; 3 | export declare const nestedAnother: string; 4 | export declare const someClass: string; 5 | export declare const myCustomClass: string; 6 | -------------------------------------------------------------------------------- /__tests__/dummy-styles/complex.scss: -------------------------------------------------------------------------------- 1 | .some-styles { 2 | color: red; 3 | } 4 | 5 | .number-1 { 6 | color: green; 7 | } 8 | 9 | .nested { 10 | &-class { 11 | background: green; 12 | } 13 | 14 | &-another { 15 | border: 1px; 16 | } 17 | } 18 | 19 | :where(.where-selector) { 20 | color: blue; 21 | } 22 | -------------------------------------------------------------------------------- /__tests__/dummy-styles/complex.scss.d.ts: -------------------------------------------------------------------------------- 1 | export declare const someStyles: string; 2 | export declare const nestedClass: string; 3 | export declare const nestedAnother: string; 4 | -------------------------------------------------------------------------------- /__tests__/dummy-styles/composes.scss: -------------------------------------------------------------------------------- 1 | .composed-class { 2 | composes: nested-styles from "./nested-styles/style.scss"; 3 | color: red; 4 | } 5 | -------------------------------------------------------------------------------- /__tests__/dummy-styles/composes.scss.d.ts: -------------------------------------------------------------------------------- 1 | export declare const composedClass: string; 2 | -------------------------------------------------------------------------------- /__tests__/dummy-styles/dashes.scss: -------------------------------------------------------------------------------- 1 | .App { 2 | background: white; 3 | 4 | .Logo { 5 | width: 50%; 6 | } 7 | } 8 | 9 | .App-Header { 10 | font-size: 2em; 11 | } 12 | -------------------------------------------------------------------------------- /__tests__/dummy-styles/dashes.scss.d.ts: -------------------------------------------------------------------------------- 1 | export declare const app: string; 2 | export declare const logo: string; 3 | export declare const appHeader: string; 4 | -------------------------------------------------------------------------------- /__tests__/dummy-styles/empty.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skovy/typed-scss-modules/e80c68e2d8369be28a17f605d298d2f2bdc16b28/__tests__/dummy-styles/empty.scss -------------------------------------------------------------------------------- /__tests__/dummy-styles/global-variables.scss: -------------------------------------------------------------------------------- 1 | .globalStyle { 2 | background-color: $global-red; 3 | } 4 | -------------------------------------------------------------------------------- /__tests__/dummy-styles/invalid.scss: -------------------------------------------------------------------------------- 1 | .random-class { 2 | color: pink; 3 | } 4 | -------------------------------------------------------------------------------- /__tests__/dummy-styles/invalid.scss.d.ts: -------------------------------------------------------------------------------- 1 | export declare const nope: string; 2 | -------------------------------------------------------------------------------- /__tests__/dummy-styles/nested-styles/style.scss: -------------------------------------------------------------------------------- 1 | .nested-styles { 2 | background: purple; 3 | } 4 | -------------------------------------------------------------------------------- /__tests__/dummy-styles/nested-styles/style.scss.d.ts: -------------------------------------------------------------------------------- 1 | export declare const nestedStyles: string; 2 | -------------------------------------------------------------------------------- /__tests__/dummy-styles/style.scss: -------------------------------------------------------------------------------- 1 | .some-class { 2 | color: red; 3 | } 4 | -------------------------------------------------------------------------------- /__tests__/dummy-styles/style.scss.d.ts: -------------------------------------------------------------------------------- 1 | export declare const someClass: string; 2 | -------------------------------------------------------------------------------- /__tests__/dummy-styles/typed-scss-modules.config.js: -------------------------------------------------------------------------------- 1 | export const config = { 2 | banner: "", 3 | watch: false, 4 | ignoreInitial: false, 5 | exportType: "named", 6 | exportTypeName: "ClassNames", 7 | exportTypeInterface: "Styles", 8 | listDifferent: false, 9 | ignore: [], 10 | quoteType: "single", 11 | updateStaleOnly: false, 12 | logLevel: "verbose", 13 | }; 14 | -------------------------------------------------------------------------------- /__tests__/helpers/index.ts: -------------------------------------------------------------------------------- 1 | import { Implementations, IMPLEMENTATIONS } from "../../lib/implementations"; 2 | 3 | export const describeAllImplementations = ( 4 | fn: (implementation: Implementations) => void 5 | ) => { 6 | IMPLEMENTATIONS.forEach((implementation) => { 7 | describe(`${implementation} implementation`, () => { 8 | fn(implementation); 9 | }); 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /__tests__/implementations/get-default-implementation.test.ts: -------------------------------------------------------------------------------- 1 | import { getDefaultImplementation } from "../../lib/implementations"; 2 | 3 | describe("getDefaultImplementation", () => { 4 | it("returns node-sass if it exists", () => { 5 | expect(getDefaultImplementation()).toBe("node-sass"); 6 | }); 7 | 8 | it("returns sass if node-sass does not exist", () => { 9 | const resolver = jest.fn((implementation) => { 10 | if (implementation === "node-sass") { 11 | throw new Error("Not Found"); 12 | } 13 | }) as unknown as RequireResolve; 14 | 15 | expect(getDefaultImplementation(resolver)).toBe("sass"); 16 | expect(resolver).toHaveBeenCalledTimes(2); 17 | expect(resolver).toHaveBeenNthCalledWith(1, "node-sass"); 18 | expect(resolver).toHaveBeenNthCalledWith(2, "sass"); 19 | }); 20 | 21 | it("returns node-sass even if both sass and node-sass do not exist", () => { 22 | const resolver = jest.fn(() => { 23 | throw new Error("Not Found"); 24 | }) as unknown as RequireResolve; 25 | 26 | expect(getDefaultImplementation(resolver)).toBe("node-sass"); 27 | expect(resolver).toHaveBeenCalledTimes(2); 28 | expect(resolver).toHaveBeenNthCalledWith(1, "node-sass"); 29 | expect(resolver).toHaveBeenNthCalledWith(2, "sass"); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /__tests__/implementations/get-implementation.test.ts: -------------------------------------------------------------------------------- 1 | import nodeSass from "node-sass"; 2 | import sass from "sass"; 3 | import { getImplementation } from "../../lib/implementations"; 4 | 5 | describe("getImplementation", () => { 6 | it("returns the correct implementation when explicitly passed", () => { 7 | expect(getImplementation("node-sass")).toEqual(nodeSass); 8 | expect(getImplementation("sass")).toEqual(sass); 9 | }); 10 | 11 | it("returns the correct default implementation if it is invalid", () => { 12 | expect( 13 | getImplementation( 14 | // @ts-expect-error invalid implementation 15 | "wat-sass" 16 | ) 17 | ).toEqual(nodeSass); 18 | expect(getImplementation()).toEqual(nodeSass); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /__tests__/load.test.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { DEFAULT_OPTIONS, loadConfig, mergeOptions } from "../lib/load"; 3 | 4 | const CONFIG_CASES = [ 5 | "js-default-export", 6 | "js-module-exports", 7 | "js-named-export", 8 | "ts-default-export", 9 | "ts-named-export", 10 | ]; 11 | 12 | describe("#loadConfig", () => { 13 | it.each(CONFIG_CASES)( 14 | "should load the '%s' config file correctly", 15 | // Spoof the current working directory so when the config file is read 16 | // we can direct it to any path we want. This makes it easier to test 17 | // various kinds of configuration files as if they were in the root. 18 | async (configCaseName) => { 19 | jest 20 | .spyOn(process, "cwd") 21 | .mockReturnValue(path.resolve(`__tests__/configs/${configCaseName}`)); 22 | 23 | const config = await loadConfig(); 24 | 25 | expect(config).toHaveProperty("banner"); 26 | expect(config).toEqual({ banner: `// ${configCaseName}` }); 27 | } 28 | ); 29 | }); 30 | 31 | describe("#mergeOptions", () => { 32 | it("should return the default options by default", () => { 33 | expect(mergeOptions({}, {})).toEqual(DEFAULT_OPTIONS); 34 | }); 35 | 36 | it("should allow overriding all default options via the CLI options", () => { 37 | expect( 38 | mergeOptions( 39 | { 40 | nameFormat: ["kebab"], 41 | implementation: "sass", 42 | exportType: "default", 43 | exportTypeName: "Classes", 44 | exportTypeInterface: "AllStyles", 45 | watch: true, 46 | ignoreInitial: true, 47 | listDifferent: true, 48 | ignore: ["path"], 49 | quoteType: "double", 50 | updateStaleOnly: true, 51 | logLevel: "silent", 52 | outputFolder: "__generated__", 53 | banner: "// override", 54 | allowArbitraryExtensions: true, 55 | }, 56 | {} 57 | ) 58 | ).toEqual({ 59 | nameFormat: ["kebab"], 60 | implementation: "sass", 61 | exportType: "default", 62 | exportTypeName: "Classes", 63 | exportTypeInterface: "AllStyles", 64 | watch: true, 65 | ignoreInitial: true, 66 | listDifferent: true, 67 | ignore: ["path"], 68 | quoteType: "double", 69 | updateStaleOnly: true, 70 | logLevel: "silent", 71 | outputFolder: "__generated__", 72 | banner: "// override", 73 | allowArbitraryExtensions: true, 74 | }); 75 | }); 76 | 77 | it("should allow overriding all default options via the config options", () => { 78 | const importer = jest.fn(); 79 | 80 | expect( 81 | mergeOptions( 82 | {}, 83 | { 84 | nameFormat: ["kebab"], 85 | implementation: "sass", 86 | exportType: "default", 87 | exportTypeName: "Classes", 88 | exportTypeInterface: "AllStyles", 89 | watch: true, 90 | ignoreInitial: true, 91 | listDifferent: true, 92 | ignore: ["path"], 93 | quoteType: "double", 94 | updateStaleOnly: true, 95 | logLevel: "silent", 96 | banner: "// override", 97 | outputFolder: "__generated__", 98 | importer, 99 | allowArbitraryExtensions: true, 100 | } 101 | ) 102 | ).toEqual({ 103 | nameFormat: ["kebab"], 104 | implementation: "sass", 105 | exportType: "default", 106 | exportTypeName: "Classes", 107 | exportTypeInterface: "AllStyles", 108 | watch: true, 109 | ignoreInitial: true, 110 | listDifferent: true, 111 | ignore: ["path"], 112 | quoteType: "double", 113 | updateStaleOnly: true, 114 | logLevel: "silent", 115 | banner: "// override", 116 | outputFolder: "__generated__", 117 | importer, 118 | allowArbitraryExtensions: true, 119 | }); 120 | }); 121 | 122 | it("should give precedence to CLI options and still merge config-only options", () => { 123 | const importer = jest.fn(); 124 | 125 | expect( 126 | mergeOptions( 127 | { 128 | nameFormat: ["kebab"], 129 | implementation: "sass", 130 | exportType: "default", 131 | exportTypeName: "Classes", 132 | exportTypeInterface: "AllStyles", 133 | watch: true, 134 | ignoreInitial: true, 135 | listDifferent: true, 136 | ignore: ["path"], 137 | quoteType: "double", 138 | updateStaleOnly: true, 139 | logLevel: "silent", 140 | banner: "// override", 141 | outputFolder: "__cli-generated__", 142 | allowArbitraryExtensions: true, 143 | }, 144 | { 145 | nameFormat: ["param"], 146 | implementation: "node-sass", 147 | exportType: "named", 148 | exportTypeName: "Classnames", 149 | exportTypeInterface: "TheStyles", 150 | watch: false, 151 | ignoreInitial: false, 152 | listDifferent: false, 153 | ignore: ["another/path"], 154 | quoteType: "single", 155 | updateStaleOnly: false, 156 | logLevel: "info", 157 | banner: "// not override", 158 | outputFolder: "__generated__", 159 | importer, 160 | } 161 | ) 162 | ).toEqual({ 163 | nameFormat: ["kebab"], 164 | implementation: "sass", 165 | exportType: "default", 166 | exportTypeName: "Classes", 167 | exportTypeInterface: "AllStyles", 168 | watch: true, 169 | ignoreInitial: true, 170 | listDifferent: true, 171 | ignore: ["path"], 172 | quoteType: "double", 173 | updateStaleOnly: true, 174 | logLevel: "silent", 175 | banner: "// override", 176 | outputFolder: "__cli-generated__", 177 | importer, 178 | allowArbitraryExtensions: true, 179 | }); 180 | }); 181 | 182 | it("should give ignore undefined CLI options", () => { 183 | const importer = jest.fn(); 184 | 185 | expect( 186 | mergeOptions( 187 | { 188 | aliases: undefined, 189 | aliasPrefixes: undefined, 190 | nameFormat: ["kebab"], 191 | implementation: "sass", 192 | exportType: "default", 193 | exportTypeName: "Classes", 194 | exportTypeInterface: "AllStyles", 195 | watch: true, 196 | ignoreInitial: true, 197 | listDifferent: true, 198 | ignore: ["path"], 199 | quoteType: "double", 200 | updateStaleOnly: true, 201 | logLevel: "silent", 202 | banner: undefined, 203 | outputFolder: "__cli-generated__", 204 | allowArbitraryExtensions: true, 205 | }, 206 | { 207 | aliases: {}, 208 | aliasPrefixes: {}, 209 | nameFormat: ["param"], 210 | implementation: "node-sass", 211 | exportType: "named", 212 | exportTypeName: "Classnames", 213 | exportTypeInterface: "TheStyles", 214 | watch: false, 215 | ignoreInitial: false, 216 | listDifferent: false, 217 | ignore: ["another/path"], 218 | quoteType: "single", 219 | updateStaleOnly: false, 220 | logLevel: "info", 221 | banner: "// banner", 222 | outputFolder: "__generated__", 223 | importer, 224 | allowArbitraryExtensions: false, 225 | } 226 | ) 227 | ).toEqual({ 228 | aliases: {}, 229 | aliasPrefixes: {}, 230 | nameFormat: ["kebab"], 231 | implementation: "sass", 232 | exportType: "default", 233 | exportTypeName: "Classes", 234 | exportTypeInterface: "AllStyles", 235 | watch: true, 236 | ignoreInitial: true, 237 | listDifferent: true, 238 | ignore: ["path"], 239 | quoteType: "double", 240 | updateStaleOnly: true, 241 | logLevel: "silent", 242 | banner: "// banner", 243 | outputFolder: "__cli-generated__", 244 | importer, 245 | allowArbitraryExtensions: true, 246 | }); 247 | }); 248 | }); 249 | -------------------------------------------------------------------------------- /__tests__/main.test.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import slash from "slash"; 4 | import { alerts } from "../lib/core"; 5 | import { main } from "../lib/main"; 6 | import { describeAllImplementations } from "./helpers"; 7 | 8 | describeAllImplementations((implementation) => { 9 | describe("main", () => { 10 | let writeFileSyncSpy: jest.SpyInstance; 11 | 12 | beforeEach(() => { 13 | // Only mock the writes, so the example files can still be read. 14 | writeFileSyncSpy = jest.spyOn(fs, "writeFileSync").mockImplementation(); 15 | 16 | // Avoid creating directories while running tests. 17 | jest.spyOn(fs, "mkdirSync").mockImplementation(); 18 | 19 | // Avoid console logs showing up. 20 | jest.spyOn(console, "log").mockImplementation(); 21 | 22 | jest.spyOn(alerts, "error").mockImplementation(); 23 | }); 24 | 25 | afterEach(() => { 26 | writeFileSyncSpy.mockReset(); 27 | }); 28 | 29 | it("generates types for all .scss files when the pattern is a directory", async () => { 30 | const pattern = `${__dirname}/dummy-styles`; 31 | 32 | await main(pattern, { 33 | banner: "", 34 | watch: false, 35 | ignoreInitial: false, 36 | exportType: "named", 37 | exportTypeName: "ClassNames", 38 | exportTypeInterface: "Styles", 39 | listDifferent: false, 40 | ignore: [], 41 | implementation, 42 | quoteType: "single", 43 | updateStaleOnly: false, 44 | logLevel: "verbose", 45 | additionalData: "$global-red: red;", 46 | aliases: { 47 | "~fancy-import": "complex", 48 | "~another": "style", 49 | }, 50 | aliasPrefixes: { 51 | "~": "nested-styles/", 52 | }, 53 | }); 54 | 55 | expect(alerts.error).not.toHaveBeenCalled(); 56 | expect(fs.writeFileSync).toHaveBeenCalledTimes(9); 57 | 58 | const expectedDirname = slash(path.join(__dirname, "dummy-styles")); 59 | 60 | expect(fs.writeFileSync).toHaveBeenCalledWith( 61 | `${expectedDirname}/complex.scss.d.ts`, 62 | "export declare const nestedAnother: string;\nexport declare const nestedClass: string;\nexport declare const number1: string;\nexport declare const someStyles: string;\nexport declare const whereSelector: string;\n" 63 | ); 64 | expect(fs.writeFileSync).toHaveBeenCalledWith( 65 | `${expectedDirname}/style.scss.d.ts`, 66 | "export declare const someClass: string;\n" 67 | ); 68 | }); 69 | 70 | it("generates types for all .scss files and ignores files that match the ignore pattern", async () => { 71 | const pattern = `${__dirname}/dummy-styles`; 72 | 73 | await main(pattern, { 74 | banner: "", 75 | watch: false, 76 | ignoreInitial: false, 77 | exportType: "named", 78 | exportTypeName: "ClassNames", 79 | exportTypeInterface: "Styles", 80 | listDifferent: false, 81 | ignore: ["**/style.scss"], 82 | implementation, 83 | quoteType: "single", 84 | updateStaleOnly: false, 85 | logLevel: "verbose", 86 | additionalData: "$global-red: red;", 87 | aliases: { 88 | "~fancy-import": "complex", 89 | "~another": "style", 90 | }, 91 | aliasPrefixes: { 92 | "~": "nested-styles/", 93 | }, 94 | }); 95 | 96 | expect(alerts.error).not.toHaveBeenCalled(); 97 | expect(fs.writeFileSync).toHaveBeenCalledTimes(7); 98 | 99 | const expectedDirname = slash(path.join(__dirname, "dummy-styles")); 100 | 101 | expect(fs.writeFileSync).toHaveBeenCalledWith( 102 | `${expectedDirname}/complex.scss.d.ts`, 103 | "export declare const nestedAnother: string;\nexport declare const nestedClass: string;\nexport declare const number1: string;\nexport declare const someStyles: string;\nexport declare const whereSelector: string;\n" 104 | ); 105 | 106 | // Files that should match the ignore pattern. 107 | expect(fs.writeFileSync).not.toHaveBeenCalledWith( 108 | `${expectedDirname}/style.scss.d.ts`, 109 | expect.anything() 110 | ); 111 | expect(fs.writeFileSync).not.toHaveBeenCalledWith( 112 | `${expectedDirname}/nested-styles/style.scss.d.ts`, 113 | expect.anything() 114 | ); 115 | }); 116 | 117 | it("reads options from the configuration file", async () => { 118 | const pattern = `${__dirname}/dummy-styles`; 119 | 120 | jest.spyOn(process, "cwd").mockReturnValue(path.resolve(pattern)); 121 | 122 | await main(pattern, { 123 | additionalData: "$global-red: red;", 124 | aliases: { 125 | "~fancy-import": "complex", 126 | "~another": "style", 127 | }, 128 | aliasPrefixes: { 129 | "~": "nested-styles/", 130 | }, 131 | exportType: "default", 132 | }); 133 | 134 | expect(alerts.error).not.toHaveBeenCalled(); 135 | expect(fs.writeFileSync).toHaveBeenCalledTimes(9); 136 | 137 | // Transform the calls into a more readable format for the snapshot. 138 | const contents = writeFileSyncSpy.mock.calls 139 | .map(([fullFilePath, contents]: [string, string]) => ({ 140 | path: path.relative(__dirname, fullFilePath), 141 | contents, 142 | })) 143 | // Sort to avoid flakey snapshot tests if call order changes. 144 | .sort((a, b) => a.path.localeCompare(b.path)); 145 | 146 | expect(contents).toMatchSnapshot(); 147 | }); 148 | 149 | it("outputs the correct files when outputFolder is passed", async () => { 150 | const pattern = path.resolve(__dirname, "dummy-styles"); 151 | 152 | await main(pattern, { 153 | additionalData: "$global-red: red;", 154 | aliases: { 155 | "~fancy-import": "complex", 156 | "~another": "style", 157 | }, 158 | aliasPrefixes: { 159 | "~": "nested-styles/", 160 | }, 161 | outputFolder: "__generated__", 162 | }); 163 | 164 | expect(alerts.error).not.toHaveBeenCalled(); 165 | expect(fs.writeFileSync).toHaveBeenCalledTimes(9); 166 | expect(fs.mkdirSync).toHaveBeenCalledTimes(9); 167 | 168 | // Transform the calls into a more readable format for the snapshot. 169 | const contents = writeFileSyncSpy.mock.calls 170 | .map(([fullFilePath, contents]: [string, string]) => ({ 171 | path: path.relative(__dirname, fullFilePath), 172 | contents, 173 | })) 174 | // Sort to avoid flakey snapshot tests if call order changes. 175 | .sort((a, b) => a.path.localeCompare(b.path)); 176 | 177 | expect(contents).toMatchSnapshot(); 178 | }); 179 | }); 180 | }); 181 | -------------------------------------------------------------------------------- /__tests__/prettier/__snapshots__/prettier.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`attemptPrettier should match snapshot 1`] = ` 4 | "export type Styles = { 5 | nestedAnother: string; 6 | nestedClass: string; 7 | someStyles: string; 8 | }; 9 | 10 | export type ClassNames = keyof Styles; 11 | 12 | declare const styles: Styles; 13 | 14 | export default styles; 15 | " 16 | `; 17 | -------------------------------------------------------------------------------- /__tests__/prettier/prettier.test.ts: -------------------------------------------------------------------------------- 1 | import { join } from "path"; 2 | import prettier from "prettier"; 3 | import { attemptPrettier } from "../../lib/prettier"; 4 | import { classNamesToTypeDefinitions } from "../../lib/typescript"; 5 | 6 | const file = join(__dirname, "test.d.ts"); 7 | const input = 8 | "export type Styles = {'myClass': string;'yourClass': string;}; export type Classes = keyof Styles; declare const styles: Styles; export default styles;"; 9 | 10 | describe("attemptPrettier", () => { 11 | it("should locate and apply prettier.format", async () => { 12 | const output = await attemptPrettier(file, input); 13 | 14 | expect(prettier.format(input, { parser: "typescript" })).toMatch(output); 15 | }); 16 | 17 | it("should match snapshot", async () => { 18 | const typeDefinition = await classNamesToTypeDefinitions({ 19 | banner: "", 20 | classNames: ["nestedAnother", "nestedClass", "someStyles"], 21 | file, 22 | exportType: "default", 23 | }); 24 | 25 | if (!typeDefinition) { 26 | throw new Error("failed to collect typeDefinition"); 27 | } 28 | 29 | const output = await attemptPrettier(file, typeDefinition); 30 | 31 | expect(output).toMatchSnapshot(); 32 | }); 33 | }); 34 | 35 | describe("attemptPrettier - mock prettier", () => { 36 | beforeAll(() => { 37 | jest.mock("prettier", () => ({ 38 | format: undefined, 39 | })); 40 | }); 41 | 42 | it("should fail to recognize prettier and return input", async () => { 43 | const output = await attemptPrettier(file, input); 44 | 45 | expect(input).toMatch(output); 46 | }); 47 | }); 48 | 49 | describe("attemptPrettier - mock resolution check", () => { 50 | beforeAll(() => { 51 | jest.mock("../../lib/prettier/can-resolve"); 52 | }); 53 | 54 | it("should fail to resolve prettier and return input", async () => { 55 | const output = await attemptPrettier(file, input); 56 | 57 | expect(input).toMatch(output); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /__tests__/sass/file-to-class-names.test.ts: -------------------------------------------------------------------------------- 1 | import { fileToClassNames } from "../../lib/sass"; 2 | import { describeAllImplementations } from "../helpers"; 3 | 4 | describeAllImplementations((implementation) => { 5 | describe("fileToClassNames", () => { 6 | it("converts a file path to an array of class names (default camel cased)", async () => { 7 | const result = await fileToClassNames( 8 | `${__dirname}/../dummy-styles/complex.scss` 9 | ); 10 | 11 | expect(result).toEqual([ 12 | "nestedAnother", 13 | "nestedClass", 14 | "number1", 15 | "someStyles", 16 | "whereSelector", 17 | ]); 18 | }); 19 | 20 | describe("nameFormat", () => { 21 | it("converts a file path to an array of class names with kebab as the name format", async () => { 22 | const result = await fileToClassNames( 23 | `${__dirname}/../dummy-styles/complex.scss`, 24 | { 25 | nameFormat: ["kebab"], 26 | implementation, 27 | } 28 | ); 29 | 30 | expect(result).toEqual([ 31 | "nested-another", 32 | "nested-class", 33 | "number-1", 34 | "some-styles", 35 | "where-selector", 36 | ]); 37 | }); 38 | 39 | it("converts a file path to an array of class names with param as the name format", async () => { 40 | const result = await fileToClassNames( 41 | `${__dirname}/../dummy-styles/complex.scss`, 42 | { 43 | nameFormat: ["param"], 44 | implementation, 45 | } 46 | ); 47 | 48 | expect(result).toEqual([ 49 | "nested-another", 50 | "nested-class", 51 | "number-1", 52 | "some-styles", 53 | "where-selector", 54 | ]); 55 | }); 56 | 57 | it("converts a file path to an array of class names with snake as the name format", async () => { 58 | const result = await fileToClassNames( 59 | `${__dirname}/../dummy-styles/complex.scss`, 60 | { 61 | nameFormat: ["snake"], 62 | implementation, 63 | } 64 | ); 65 | 66 | expect(result).toEqual([ 67 | "nested_another", 68 | "nested_class", 69 | "number_1", 70 | "some_styles", 71 | "where_selector", 72 | ]); 73 | }); 74 | 75 | it("converts a file path to an array of class names where only classes with dashes in the names are altered", async () => { 76 | const result = await fileToClassNames( 77 | `${__dirname}/../dummy-styles/dashes.scss`, 78 | { 79 | nameFormat: ["dashes"], 80 | implementation, 81 | } 82 | ); 83 | 84 | expect(result).toEqual(["App", "appHeader", "Logo"]); 85 | }); 86 | 87 | it("does not change class names when nameFormat is set to none", async () => { 88 | const result = await fileToClassNames( 89 | `${__dirname}/../dummy-styles/dashes.scss`, 90 | { 91 | nameFormat: ["none"], 92 | implementation, 93 | } 94 | ); 95 | 96 | expect(result).toEqual(["App", "App-Header", "Logo"]); 97 | }); 98 | 99 | it("applies all transformers when is set to all", async () => { 100 | const result = await fileToClassNames( 101 | `${__dirname}/../dummy-styles/complex.scss`, 102 | { 103 | nameFormat: ["all"], 104 | implementation, 105 | } 106 | ); 107 | 108 | expect(result).toEqual([ 109 | "nested_another", 110 | "nested_class", 111 | "nested-another", 112 | "nested-class", 113 | "nestedAnother", 114 | "nestedClass", 115 | "number_1", 116 | "number-1", 117 | "number1", 118 | "some_styles", 119 | "some-styles", 120 | "someStyles", 121 | "where_selector", 122 | "where-selector", 123 | "whereSelector", 124 | ]); 125 | }); 126 | 127 | it("applies multiple transformers when sent as an array", async () => { 128 | const result = await fileToClassNames( 129 | `${__dirname}/../dummy-styles/complex.scss`, 130 | { 131 | nameFormat: ["kebab", "snake"], 132 | implementation, 133 | } 134 | ); 135 | 136 | expect(result).toEqual([ 137 | "nested_another", 138 | "nested_class", 139 | "nested-another", 140 | "nested-class", 141 | "number_1", 142 | "number-1", 143 | "some_styles", 144 | "some-styles", 145 | "where_selector", 146 | "where-selector", 147 | ]); 148 | }); 149 | 150 | it("handles only a string", async () => { 151 | const result = await fileToClassNames( 152 | `${__dirname}/../dummy-styles/complex.scss`, 153 | { 154 | nameFormat: "snake", 155 | implementation, 156 | } 157 | ); 158 | 159 | expect(result).toEqual([ 160 | "nested_another", 161 | "nested_class", 162 | "number_1", 163 | "some_styles", 164 | "where_selector", 165 | ]); 166 | }); 167 | }); 168 | 169 | describe("aliases", () => { 170 | it("converts a file that contains aliases", async () => { 171 | const result = await fileToClassNames( 172 | `${__dirname}/../dummy-styles/aliases.scss`, 173 | { 174 | aliases: { 175 | "~fancy-import": "complex", 176 | "~another": "style", 177 | }, 178 | implementation, 179 | } 180 | ); 181 | 182 | expect(result).toEqual([ 183 | "myCustomClass", 184 | "nestedAnother", 185 | "nestedClass", 186 | "number1", 187 | "someClass", 188 | "someStyles", 189 | "whereSelector", 190 | ]); 191 | }); 192 | }); 193 | 194 | describe("aliasPrefixes", () => { 195 | it("converts a file that contains alias prefixes (but prioritizes aliases)", async () => { 196 | const result = await fileToClassNames( 197 | `${__dirname}/../dummy-styles/alias-prefixes.scss`, 198 | { 199 | aliases: { 200 | "~fancy-import": "complex", 201 | }, 202 | aliasPrefixes: { 203 | "~": "nested-styles/", 204 | }, 205 | implementation, 206 | } 207 | ); 208 | 209 | expect(result).toEqual([ 210 | "myCustomClass", 211 | "nestedAnother", 212 | "nestedClass", 213 | "nestedStyles", 214 | "number1", 215 | "someStyles", 216 | "whereSelector", 217 | ]); 218 | }); 219 | }); 220 | 221 | describe("composes", () => { 222 | it("converts a file that contains a composes dependency from another file", async () => { 223 | const result = await fileToClassNames( 224 | `${__dirname}/../dummy-styles/composes.scss`, 225 | { 226 | implementation, 227 | } 228 | ); 229 | 230 | expect(result).toEqual(["composedClass"]); 231 | }); 232 | }); 233 | 234 | describe("additionalData", () => { 235 | it("adds additional data to enable adding any necessary context", async () => { 236 | const result = await fileToClassNames( 237 | `${__dirname}/../dummy-styles/global-variables.scss`, 238 | { 239 | implementation, 240 | additionalData: "$global-red: red;", 241 | } 242 | ); 243 | 244 | expect(result).toEqual(["globalStyle"]); 245 | }); 246 | }); 247 | }); 248 | }); 249 | -------------------------------------------------------------------------------- /__tests__/sass/importer.test.ts: -------------------------------------------------------------------------------- 1 | import { SyncContext } from "node-sass"; 2 | import { LegacyImporterThis } from "sass"; 3 | import { aliasImporter, customImporters } from "../../lib/sass/importer"; 4 | 5 | // SASS importers receive two other arguments that this package doesn't care about. 6 | // Fake `this` which the type definitions both define for importers. 7 | const fakeImporterThis = {} as LegacyImporterThis & SyncContext; 8 | const fakePrev = ""; 9 | 10 | describe("#aliasImporter", () => { 11 | it("should create an importer to replace aliases and otherwise return null", () => { 12 | const importer = aliasImporter({ 13 | aliases: { input: "output", "~alias": "node_modules" }, 14 | aliasPrefixes: {}, 15 | }); 16 | 17 | expect(importer.call(fakeImporterThis, "input", fakePrev)).toEqual({ 18 | file: "output", 19 | }); 20 | expect(importer.call(fakeImporterThis, "~alias", fakePrev)).toEqual({ 21 | file: "node_modules", 22 | }); 23 | expect(importer.call(fakeImporterThis, "output", fakePrev)).toBeNull(); 24 | expect( 25 | importer.call(fakeImporterThis, "input-substring", fakePrev) 26 | ).toBeNull(); 27 | expect(importer.call(fakeImporterThis, "other", fakePrev)).toBeNull(); 28 | }); 29 | 30 | it("should create an importer to replace alias prefixes and otherwise return null", () => { 31 | const importer = aliasImporter({ 32 | aliases: {}, 33 | aliasPrefixes: { "~": "node_modules/", abc: "def" }, 34 | }); 35 | 36 | expect(importer.call(fakeImporterThis, "abc-123", fakePrev)).toEqual({ 37 | file: "def-123", 38 | }); 39 | expect(importer.call(fakeImporterThis, "~package", fakePrev)).toEqual({ 40 | file: "node_modules/package", 41 | }); 42 | expect(importer.call(fakeImporterThis, "output~", fakePrev)).toBeNull(); 43 | expect( 44 | importer.call(fakeImporterThis, "input-substring-abc", fakePrev) 45 | ).toBeNull(); 46 | expect(importer.call(fakeImporterThis, "other", fakePrev)).toBeNull(); 47 | }); 48 | }); 49 | 50 | describe("#customImporters", () => { 51 | beforeEach(() => { 52 | console.log = jest.fn(); // avoid console logs showing up 53 | }); 54 | 55 | it("should return only an alias importer by default", () => { 56 | const importers = customImporters({ 57 | aliases: { "~alias": "secret/path" }, 58 | aliasPrefixes: { "~": "node_modules/" }, 59 | }); 60 | 61 | expect(importers).toHaveLength(1); 62 | 63 | const [aliasImporter] = importers; 64 | 65 | expect(aliasImporter.call(fakeImporterThis, "~package", fakePrev)).toEqual({ 66 | file: "node_modules/package", 67 | }); 68 | expect(aliasImporter.call(fakeImporterThis, "~alias", fakePrev)).toEqual({ 69 | file: "secret/path", 70 | }); 71 | expect(aliasImporter.call(fakeImporterThis, "other", fakePrev)).toBeNull(); 72 | }); 73 | 74 | it("should add additional importers if passed a function", () => { 75 | const importer = jest.fn(); 76 | 77 | const importers = customImporters({ 78 | aliases: {}, 79 | aliasPrefixes: {}, 80 | importer, 81 | }); 82 | 83 | expect(importers).toHaveLength(2); 84 | expect(importers[1]).toEqual(importer); 85 | }); 86 | 87 | it("should add multiple importers if passed an array", () => { 88 | const importer1 = jest.fn(); 89 | const importer2 = jest.fn(); 90 | const importer3 = jest.fn(); 91 | 92 | const importers = customImporters({ 93 | aliases: {}, 94 | aliasPrefixes: {}, 95 | importer: [importer1, importer2, importer3], 96 | }); 97 | 98 | expect(importers).toHaveLength(4); 99 | expect(importers[1]).toEqual(importer1); 100 | expect(importers[2]).toEqual(importer2); 101 | expect(importers[3]).toEqual(importer3); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /__tests__/typescript/class-names-to-type-definitions.test.ts: -------------------------------------------------------------------------------- 1 | import os from "os"; 2 | import { join } from "path"; 3 | import { classNamesToTypeDefinitions } from "../../lib/typescript"; 4 | 5 | jest.mock("../../lib/prettier/can-resolve", () => ({ 6 | canResolvePrettier: () => false, 7 | })); 8 | const file = join(__dirname, "test.d.ts"); 9 | 10 | describe("classNamesToTypeDefinitions (without Prettier)", () => { 11 | beforeEach(() => { 12 | console.log = jest.fn(); 13 | }); 14 | 15 | describe("named", () => { 16 | it("converts an array of class name strings to type definitions", async () => { 17 | const definition = await classNamesToTypeDefinitions({ 18 | banner: "", 19 | classNames: ["myClass", "yourClass"], 20 | exportType: "named", 21 | file, 22 | }); 23 | 24 | expect(definition).toEqual( 25 | "export declare const myClass: string;\nexport declare const yourClass: string;\n" 26 | ); 27 | }); 28 | 29 | it("returns null if there are no class names", async () => { 30 | const definition = await classNamesToTypeDefinitions({ 31 | banner: "", 32 | classNames: [], 33 | exportType: "named", 34 | file, 35 | }); 36 | 37 | expect(definition).toBeNull(); 38 | }); 39 | 40 | it("prints a warning if a classname is a reserved keyword and does not include it in the type definitions", async () => { 41 | const definition = await classNamesToTypeDefinitions({ 42 | banner: "", 43 | classNames: ["myClass", "if"], 44 | exportType: "named", 45 | file, 46 | }); 47 | 48 | expect(definition).toEqual("export declare const myClass: string;\n"); 49 | expect(console.log).toHaveBeenCalledWith( 50 | expect.stringContaining(`[SKIPPING] 'if' is a reserved keyword`) 51 | ); 52 | }); 53 | 54 | it("prints a warning if a classname is invalid and does not include it in the type definitions", async () => { 55 | const definition = await classNamesToTypeDefinitions({ 56 | banner: "", 57 | classNames: ["myClass", "invalid-variable"], 58 | exportType: "named", 59 | file, 60 | }); 61 | 62 | expect(definition).toEqual("export declare const myClass: string;\n"); 63 | expect(console.log).toHaveBeenCalledWith( 64 | expect.stringContaining(`[SKIPPING] 'invalid-variable' contains dashes`) 65 | ); 66 | }); 67 | }); 68 | 69 | describe("default", () => { 70 | it("converts an array of class name strings to type definitions", async () => { 71 | const definition = await classNamesToTypeDefinitions({ 72 | banner: "", 73 | classNames: ["myClass", "yourClass"], 74 | exportType: "default", 75 | file, 76 | }); 77 | 78 | expect(definition).toEqual( 79 | "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" 80 | ); 81 | }); 82 | 83 | it("returns null if there are no class names", async () => { 84 | const definition = await classNamesToTypeDefinitions({ 85 | banner: "", 86 | classNames: [], 87 | exportType: "default", 88 | file, 89 | }); 90 | 91 | expect(definition).toBeNull(); 92 | }); 93 | }); 94 | 95 | describe("invalid export type", () => { 96 | it("returns null", async () => { 97 | const definition = await classNamesToTypeDefinitions({ 98 | banner: "", 99 | classNames: ["myClass"], 100 | // @ts-expect-error -- invalid export type 101 | exportType: "invalid", 102 | file, 103 | }); 104 | 105 | expect(definition).toBeNull(); 106 | }); 107 | }); 108 | 109 | describe("quoteType", () => { 110 | it("uses double quotes for default exports when specified", async () => { 111 | const definition = await classNamesToTypeDefinitions({ 112 | banner: "", 113 | classNames: ["myClass", "yourClass"], 114 | exportType: "default", 115 | quoteType: "double", 116 | file, 117 | }); 118 | 119 | expect(definition).toEqual( 120 | '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' 121 | ); 122 | }); 123 | 124 | it("does not affect named exports", async () => { 125 | const definition = await classNamesToTypeDefinitions({ 126 | banner: "", 127 | classNames: ["myClass", "yourClass"], 128 | exportType: "named", 129 | quoteType: "double", 130 | file, 131 | }); 132 | 133 | expect(definition).toEqual( 134 | "export declare const myClass: string;\nexport declare const yourClass: string;\n" 135 | ); 136 | }); 137 | }); 138 | 139 | describe("exportType name and type attributes", () => { 140 | it("uses custom value for ClassNames type name", async () => { 141 | const definition = await classNamesToTypeDefinitions({ 142 | banner: "", 143 | classNames: ["myClass", "yourClass"], 144 | exportType: "default", 145 | exportTypeName: "Classes", 146 | file, 147 | }); 148 | 149 | expect(definition).toEqual( 150 | "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" 151 | ); 152 | }); 153 | 154 | it("uses custom value for Styles type name", async () => { 155 | const definition = await classNamesToTypeDefinitions({ 156 | banner: "", 157 | classNames: ["myClass", "yourClass"], 158 | exportType: "default", 159 | exportTypeInterface: "IStyles", 160 | file, 161 | }); 162 | 163 | expect(definition).toEqual( 164 | "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" 165 | ); 166 | }); 167 | }); 168 | 169 | describe("Banner support", () => { 170 | const firstLine = (str: string): string => str.split(os.EOL)[0]; 171 | 172 | it("appends the banner to the top of the output file: default", async () => { 173 | const banner = "// Example banner"; 174 | const definition = await classNamesToTypeDefinitions({ 175 | banner, 176 | classNames: ["myClass", "yourClass"], 177 | exportType: "default", 178 | file, 179 | }); 180 | 181 | expect(firstLine(definition!)).toBe(banner); 182 | }); 183 | 184 | it("appends the banner to the top of the output file: named", async () => { 185 | const banner = "// Example banner"; 186 | const definition = await classNamesToTypeDefinitions({ 187 | banner, 188 | classNames: ["myClass", "yourClass"], 189 | exportType: "named", 190 | file, 191 | }); 192 | 193 | expect(firstLine(definition!)).toBe(banner); 194 | }); 195 | }); 196 | }); 197 | -------------------------------------------------------------------------------- /__tests__/typescript/get-type-definition-path.test.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { DEFAULT_OPTIONS } from "../../lib/load"; 3 | import { getTypeDefinitionPath } from "../../lib/typescript"; 4 | 5 | describe("getTypeDefinitionPath", () => { 6 | const cssFilePath = path.resolve(process.cwd(), "some/path/style.scss"); 7 | 8 | it("returns the type definition path", () => { 9 | const outputPath = getTypeDefinitionPath(cssFilePath, DEFAULT_OPTIONS); 10 | 11 | expect(outputPath).toEqual(`${cssFilePath}.d.ts`); 12 | }); 13 | 14 | describe("when outputFolder is passed", () => { 15 | it("returns the type definition path", () => { 16 | const outputPath = getTypeDefinitionPath(cssFilePath, { 17 | ...DEFAULT_OPTIONS, 18 | outputFolder: "__generated__", 19 | }); 20 | 21 | const generatedFilePath = path.resolve( 22 | process.cwd(), 23 | "__generated__/some/path/style.scss.d.ts" 24 | ); 25 | 26 | expect(outputPath).toEqual(generatedFilePath); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | presets: [ 4 | ["@babel/preset-env", { targets: { node: "current" } }], 5 | "@babel/preset-typescript", 6 | ], 7 | plugins: ["babel-plugin-transform-import-meta"], 8 | }; 9 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { extends: ["@commitlint/config-conventional"] }; 3 | -------------------------------------------------------------------------------- /docs/typed-scss-modules-example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skovy/typed-scss-modules/e80c68e2d8369be28a17f605d298d2f2bdc16b28/docs/typed-scss-modules-example.gif -------------------------------------------------------------------------------- /examples/basic/README.md: -------------------------------------------------------------------------------- 1 | # Basic example 2 | 3 | This example contains: 4 | 5 | - Core variables (`core/variables.scss`) 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 | npm run typed-scss-modules "examples/basic/**/*.scss" -- --includePaths examples/basic/core --aliases.~alias variables --banner '// example banner' 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.scss` since there are no classes. 18 | -------------------------------------------------------------------------------- /examples/basic/core/variables.scss: -------------------------------------------------------------------------------- 1 | $blue: blue; 2 | $yellow: yellow; 3 | -------------------------------------------------------------------------------- /examples/basic/feature-a/index.ts: -------------------------------------------------------------------------------- 1 | import styles from "./style.scss"; 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.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | 3 | .text { 4 | color: $blue; 5 | 6 | &-highlighted { 7 | color: $yellow; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /examples/basic/feature-a/style.scss.d.ts: -------------------------------------------------------------------------------- 1 | // example banner 2 | export declare const text: string; 3 | export declare const textHighlighted: string; 4 | -------------------------------------------------------------------------------- /examples/basic/feature-b/index.ts: -------------------------------------------------------------------------------- 1 | import styles from "./style.scss"; 2 | 3 | console.log(styles.topBanner); 4 | // console.log(styles.somethingElse) <- Invalid: Property 'somethingElse' does not exist 5 | -------------------------------------------------------------------------------- /examples/basic/feature-b/style.scss: -------------------------------------------------------------------------------- 1 | @import "~alias"; 2 | 3 | .top-banner { 4 | background: $yellow; 5 | } 6 | -------------------------------------------------------------------------------- /examples/basic/feature-b/style.scss.d.ts: -------------------------------------------------------------------------------- 1 | // example banner 2 | export declare const topBanner: string; 3 | -------------------------------------------------------------------------------- /examples/config-file/README.md: -------------------------------------------------------------------------------- 1 | # Configuration file example 2 | 3 | This example contains: 4 | 5 | - A custom configuration file with a custom SASS importer. You should only need a custom importer if you also use one as part of your build tool. 6 | 7 | The command to generate the proper type files would look like this (_in the root of this example, not repository_): 8 | 9 | ```bash 10 | npm run typed-scss-modules "./**/*.scss" 11 | ``` 12 | -------------------------------------------------------------------------------- /examples/config-file/feature/a.scss: -------------------------------------------------------------------------------- 1 | @import "variables.json"; 2 | 3 | .class-name { 4 | color: $color-red; 5 | } 6 | -------------------------------------------------------------------------------- /examples/config-file/feature/a.scss.d.ts: -------------------------------------------------------------------------------- 1 | // config file banner 2 | export type Styles = { 3 | "class-name": string; 4 | }; 5 | 6 | export type ClassNames = keyof Styles; 7 | 8 | declare const styles: Styles; 9 | 10 | export default styles; 11 | -------------------------------------------------------------------------------- /examples/config-file/feature/variables.json: -------------------------------------------------------------------------------- 1 | { 2 | "color-red": "#c33", 3 | "color-blue": "#33c" 4 | } 5 | -------------------------------------------------------------------------------- /examples/config-file/typed-scss-modules.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | // eslint-disable-next-line @typescript-eslint/no-var-requires 3 | const jsonImporter = require("node-sass-json-importer"); 4 | 5 | export const config = { 6 | aliases: { "not-real": "test-value" }, 7 | aliasPrefixes: { "also-not-real": "test-value" }, 8 | banner: "// config file banner", 9 | nameFormat: "kebab", 10 | exportType: "default", 11 | importer: jsonImporter(), 12 | }; 13 | -------------------------------------------------------------------------------- /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 | npm run typed-scss-modules "examples/default-export/**/*.scss" -- --exportType default --nameFormat kebab --banner '// example banner' 12 | ``` 13 | -------------------------------------------------------------------------------- /examples/default-export/feature-a/index.ts: -------------------------------------------------------------------------------- 1 | import styles, { ClassNames, Styles } from "./style.scss"; 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.scss: -------------------------------------------------------------------------------- 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.scss.d.ts: -------------------------------------------------------------------------------- 1 | // example banner 2 | export type Styles = { 3 | i: string; 4 | "i-am-kebab-cased": string; 5 | while: string; 6 | }; 7 | 8 | export type ClassNames = keyof Styles; 9 | 10 | declare const styles: Styles; 11 | 12 | export default styles; 13 | -------------------------------------------------------------------------------- /examples/output-folder/README.md: -------------------------------------------------------------------------------- 1 | # Output folder example 2 | 3 | This example contains: 4 | 5 | - A custom configuration file with a custom output folder. All type definitions will be generated in this directory. 6 | 7 | The command to generate the proper type files would look like this (_in the root of this example, not repository_): 8 | 9 | ```bash 10 | yarn typed-scss-modules "./**/*.scss" 11 | ``` 12 | -------------------------------------------------------------------------------- /examples/output-folder/__generated__/examples/output-folder/feature-a/a.scss.d.ts: -------------------------------------------------------------------------------- 1 | export declare const a: string; 2 | -------------------------------------------------------------------------------- /examples/output-folder/__generated__/examples/output-folder/feature-b/b.scss.d.ts: -------------------------------------------------------------------------------- 1 | export declare const b: string; 2 | -------------------------------------------------------------------------------- /examples/output-folder/__generated__/examples/output-folder/feature-c/c.scss.d.ts: -------------------------------------------------------------------------------- 1 | export declare const c: string; 2 | -------------------------------------------------------------------------------- /examples/output-folder/__generated__/examples/output-folder/feature-c/nested/nested.scss.d.ts: -------------------------------------------------------------------------------- 1 | export declare const nested: string; 2 | -------------------------------------------------------------------------------- /examples/output-folder/feature-a/a.scss: -------------------------------------------------------------------------------- 1 | .a { 2 | color: aqua; 3 | color: $text-color; 4 | } 5 | -------------------------------------------------------------------------------- /examples/output-folder/feature-a/index.ts: -------------------------------------------------------------------------------- 1 | import styles from "./a.scss"; 2 | 3 | console.log(styles.a); 4 | -------------------------------------------------------------------------------- /examples/output-folder/feature-b/b.scss: -------------------------------------------------------------------------------- 1 | .b { 2 | background: blue; 3 | color: $text-color; 4 | } 5 | -------------------------------------------------------------------------------- /examples/output-folder/feature-b/index.ts: -------------------------------------------------------------------------------- 1 | import styles from "./b.scss"; 2 | 3 | console.log(styles.b); 4 | -------------------------------------------------------------------------------- /examples/output-folder/feature-c/c.scss: -------------------------------------------------------------------------------- 1 | .c { 2 | background: crimson; 3 | color: $text-color; 4 | } 5 | -------------------------------------------------------------------------------- /examples/output-folder/feature-c/index.ts: -------------------------------------------------------------------------------- 1 | import styles from "./c.scss"; 2 | 3 | console.log(styles.c); 4 | -------------------------------------------------------------------------------- /examples/output-folder/feature-c/nested/index.ts: -------------------------------------------------------------------------------- 1 | import styles from "./nested.scss"; 2 | 3 | console.log(styles.nested); 4 | -------------------------------------------------------------------------------- /examples/output-folder/feature-c/nested/nested.scss: -------------------------------------------------------------------------------- 1 | .nested { 2 | background: navy; 3 | color: $text-color; 4 | } 5 | -------------------------------------------------------------------------------- /examples/output-folder/typed-scss-modules.config.js: -------------------------------------------------------------------------------- 1 | export const config = { 2 | // Note: you likely would only need `__generated`. 3 | // This example includes `examples/output-folder` because it's nested within a project. 4 | outputFolder: "__generated__/examples/output-folder", 5 | // Example of declaring additional data for every stylesheet. 6 | // This could also be an import to global variables or mixins. 7 | additionalData: "$text-color: black;", 8 | }; 9 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | clearMocks: true, 3 | testMatch: ["**/__tests__/**/*.test.ts"], 4 | testPathIgnorePatterns: [ 5 | "/dist/", 6 | "/node_modules/", 7 | "(.*).d.ts", 8 | ], 9 | transformIgnorePatterns: [ 10 | "[/\\\\]node_modules[/\\\\](?!bundle-require).+\\.js$", 11 | ], 12 | }; 13 | -------------------------------------------------------------------------------- /lib/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import yargs from "yargs"; 4 | import { IMPLEMENTATIONS } from "./implementations"; 5 | import { main } from "./main"; 6 | import { Aliases, NAME_FORMATS } from "./sass"; 7 | import { EXPORT_TYPES, LOG_LEVELS, QUOTE_TYPES } from "./typescript"; 8 | 9 | const { _: patterns, ...rest } = yargs 10 | .usage( 11 | "Generate .scss.d.ts from CSS module .scss files.\nUsage: $0 [options]" 12 | ) 13 | .example("$0 src", "All .scss files at any level in the src directory") 14 | .example( 15 | "$0 src/**/*.scss", 16 | "All .scss files at any level in the src directory" 17 | ) 18 | .example( 19 | "$0 src/**/*.scss --watch", 20 | "Watch all .scss files at any level in the src directory that are added or changed" 21 | ) 22 | .example( 23 | "$0 src/**/*.scss --includePaths src/core src/variables", 24 | 'Search the "core" and "variables" directory when resolving imports' 25 | ) 26 | .example( 27 | "$0 src/**/*.scss --aliases.~name variables", 28 | 'Replace all imports for "~name" with "variables"' 29 | ) 30 | .example( 31 | "$0 src/**/*.scss --aliasPrefixes.~ ./node_modules/", 32 | 'Replace the "~" prefix with "./node_modules/" for all imports beginning with "~"' 33 | ) 34 | .example( 35 | "$0 src/**/*.scss --ignore **/secret.scss", 36 | 'Ignore any file names "secret.scss"' 37 | ) 38 | .example( 39 | "$0 src/**/*.scss --implementation sass", 40 | "Use the Dart SASS package" 41 | ) 42 | .example( 43 | "$0 src/**/*.scss -e default --quoteType double", 44 | "Use double quotes around class name definitions rather than single quotes." 45 | ) 46 | .example("$0 src/**/*.scss --logLevel error", "Output only errors") 47 | .demandCommand(1) 48 | .option("additionalData", { 49 | string: true, 50 | alias: "d", 51 | describe: "Prepends the SCSS code before each file.", 52 | }) 53 | .option("aliases", { 54 | coerce: (obj: Aliases): Aliases => obj, 55 | alias: "a", 56 | describe: "Alias any import to any other value.", 57 | }) 58 | .option("aliasPrefixes", { 59 | coerce: (obj: Aliases): Aliases => obj, 60 | alias: "p", 61 | describe: "A prefix for any import to rewrite to another value.", 62 | }) 63 | .option("nameFormat", { 64 | alias: "n", 65 | array: true, 66 | string: true, 67 | choices: NAME_FORMATS, 68 | describe: "The name format that should be used to transform class names.", 69 | }) 70 | .option("implementation", { 71 | choices: IMPLEMENTATIONS, 72 | describe: 73 | "The SASS package to used to compile. This will default to the sass implementation you have installed.", 74 | }) 75 | .option("exportType", { 76 | choices: EXPORT_TYPES, 77 | alias: "e", 78 | describe: "The type of export used for defining the type definitions.", 79 | }) 80 | .option("exportTypeName", { 81 | string: true, 82 | describe: 83 | 'Set a custom type name for styles when --exportType is "default."', 84 | }) 85 | .option("exportTypeInterface", { 86 | string: true, 87 | describe: 88 | 'Set a custom interface name for styles when --exportType is "default."', 89 | }) 90 | .option("watch", { 91 | boolean: true, 92 | alias: "w", 93 | describe: 94 | "Watch for added or changed files and (re-)generate the type definitions.", 95 | }) 96 | .option("ignoreInitial", { 97 | boolean: true, 98 | describe: "Skips the initial build when passing the watch flag.", 99 | }) 100 | .option("listDifferent", { 101 | boolean: true, 102 | alias: "l", 103 | describe: 104 | "List any type definitions that are different than those that would be generated.", 105 | }) 106 | .option("includePaths", { 107 | array: true, 108 | string: true, 109 | alias: "i", 110 | describe: "Additional paths to include when trying to resolve imports.", 111 | }) 112 | .option("ignore", { 113 | string: true, 114 | array: true, 115 | describe: "Add a pattern or an array of glob patterns to exclude matches.", 116 | }) 117 | .option("outputFolder", { 118 | string: true, 119 | alias: "o", 120 | describe: 121 | "Define a (relative) folder to output the generated type definitions. Note this requires adding the output folder to tsconfig.json `rootDirs`.", 122 | }) 123 | .options("quoteType", { 124 | choices: QUOTE_TYPES, 125 | alias: "q", 126 | describe: 127 | "Specify the quote type so that generated files adhere to your TypeScript rules.", 128 | }) 129 | .options("updateStaleOnly", { 130 | boolean: true, 131 | alias: "u", 132 | describe: 133 | "Overwrite generated files only if the source file has more recent changes.", 134 | }) 135 | .option("logLevel", { 136 | choices: LOG_LEVELS, 137 | alias: "L", 138 | describe: "Verbosity level of console output", 139 | }) 140 | .options("banner", { 141 | string: true, 142 | describe: 143 | "Inserts text at the top of every output file for documentation purposes.", 144 | }) 145 | .options("allowArbitraryExtensions", { 146 | boolean: true, 147 | describe: 148 | 'Output filenames that will be compatible with the "arbitrary file extensions" feature that was introduced in TypeScript 5.0.', 149 | }) 150 | .parseSync(); 151 | 152 | // eslint-disable-next-line @typescript-eslint/no-floating-promises 153 | main(patterns[0] as string, { ...rest }); 154 | -------------------------------------------------------------------------------- /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 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | type CbFunc = (...args: any[]) => void; 16 | type WrappedCbFunc = ( 17 | ...args: Parameters 18 | ) => ReturnType | void; 19 | /** 20 | * wraps a callback and only calls it if currentLogLevel is undefined or included in permittedLogLevels 21 | * @param permittedLogLevels list of log levels. callbacks will only be called if current log level is listed here 22 | * @param cb callback 23 | */ 24 | const withLogLevelsRestriction = 25 | (permittedLogLevels: LogLevel[], cb: T): WrappedCbFunc => 26 | (...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 { alerts } from "./alerts"; 2 | import { listFilesAndPerformSanityChecks } from "./list-files-and-perform-sanity-checks"; 3 | import { ConfigOptions } from "./types"; 4 | import { writeFile } from "./write-file"; 5 | 6 | /** 7 | * Given a file glob generate the corresponding types once. 8 | * 9 | * @param pattern the file pattern to generate type definitions for 10 | * @param options the CLI options 11 | */ 12 | export const generate = async ( 13 | pattern: string, 14 | options: ConfigOptions 15 | ): Promise => { 16 | const files = listFilesAndPerformSanityChecks(pattern, options); 17 | 18 | if (files.length === 0) { 19 | return; 20 | } 21 | 22 | alerts.success( 23 | `Found ${files.length} file${ 24 | files.length === 1 ? `` : `s` 25 | }. Generating type definitions...` 26 | ); 27 | 28 | // Wait for all the type definitions to be written. 29 | await Promise.all(files.map((file) => writeFile(file, options))); 30 | }; 31 | -------------------------------------------------------------------------------- /lib/core/index.ts: -------------------------------------------------------------------------------- 1 | export { alerts, setAlertsLogLevel } from "./alerts"; 2 | export { generate } from "./generate"; 3 | export { listDifferent } from "./list-different"; 4 | export { CLIOptions, ConfigOptions } from "./types"; 5 | export { watch } from "./watch"; 6 | export { writeFile } from "./write-file"; 7 | -------------------------------------------------------------------------------- /lib/core/list-different.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { fileToClassNames } from "../sass"; 3 | import { 4 | classNamesToTypeDefinitions, 5 | getTypeDefinitionPath, 6 | } from "../typescript"; 7 | import { alerts } from "./alerts"; 8 | import { listFilesAndPerformSanityChecks } from "./list-files-and-perform-sanity-checks"; 9 | import { ConfigOptions } from "./types"; 10 | 11 | export const listDifferent = async ( 12 | pattern: string, 13 | options: ConfigOptions 14 | ): Promise => { 15 | const files = listFilesAndPerformSanityChecks(pattern, options); 16 | 17 | // Wait for all the files to be checked. 18 | const validChecks = await Promise.all( 19 | files.map((file) => checkFile(file, options)) 20 | ); 21 | if (validChecks.includes(false)) { 22 | process.exit(1); 23 | } 24 | }; 25 | 26 | export const checkFile = async ( 27 | file: string, 28 | options: ConfigOptions 29 | ): Promise => { 30 | try { 31 | const classNames = await fileToClassNames(file, options); 32 | const typeDefinition = await classNamesToTypeDefinitions({ 33 | classNames: classNames, 34 | file, 35 | ...options, 36 | }); 37 | 38 | if (!typeDefinition) { 39 | // Assume if no type defs are necessary it's fine 40 | return true; 41 | } 42 | 43 | const path = getTypeDefinitionPath(file, options); 44 | if (!fs.existsSync(path)) { 45 | alerts.error( 46 | `[INVALID TYPES] Type file needs to be generated for ${file} ` 47 | ); 48 | return false; 49 | } 50 | 51 | const content = fs.readFileSync(path, { encoding: "utf8" }); 52 | if (content !== typeDefinition) { 53 | alerts.error(`[INVALID TYPES] Check type definitions for ${file}`); 54 | return false; 55 | } 56 | 57 | return true; 58 | } catch (error) { 59 | alerts.error( 60 | `An error occurred checking ${file}:\n${JSON.stringify(error)}` 61 | ); 62 | return false; 63 | } 64 | }; 65 | -------------------------------------------------------------------------------- /lib/core/list-files-and-perform-sanity-checks.ts: -------------------------------------------------------------------------------- 1 | import glob from "glob"; 2 | import { alerts } from "./alerts"; 3 | import { ConfigOptions } from "./types"; 4 | 5 | /** 6 | * Return the files matching the given pattern and alert the user if only 0 or 1 7 | * files matched. 8 | * 9 | * @param pattern the file pattern to generate type definitions for 10 | * @param options the CLI options 11 | */ 12 | export function listFilesAndPerformSanityChecks( 13 | pattern: string, 14 | options: ConfigOptions 15 | ): string[] { 16 | // Find all the files that match the provided pattern. 17 | const files = glob.sync(pattern, { ignore: options.ignore }); 18 | 19 | if (!files || !files.length) { 20 | alerts.error("No files found."); 21 | } 22 | 23 | // This case still works as expected but it's easy to do on accident so 24 | // provide a (hopefully) helpful warning. 25 | if (files.length === 1) { 26 | alerts.warn( 27 | `Only 1 file found for ${pattern}. If using a glob pattern (eg: dir/**/*.scss) make sure to wrap in quotes (eg: "dir/**/*.scss").` 28 | ); 29 | } 30 | 31 | return files; 32 | } 33 | -------------------------------------------------------------------------------- /lib/core/remove-file.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { ConfigOptions } from "."; 3 | import { getTypeDefinitionPath } from "../typescript"; 4 | import { alerts } from "./alerts"; 5 | 6 | /** 7 | * Given a single file remove the file 8 | * 9 | * @param file any file to remove 10 | */ 11 | 12 | const removeFile = (file: string): void => { 13 | try { 14 | if (fs.existsSync(file)) { 15 | fs.unlinkSync(file); 16 | alerts.success(`[REMOVED] ${file}`); 17 | } 18 | } catch (error) { 19 | alerts.error( 20 | `An error occurred removing ${file}:\n${JSON.stringify(error)}` 21 | ); 22 | } 23 | }; 24 | 25 | /** 26 | * Given a single file remove the generated types if they exist 27 | * 28 | * @param file the SCSS file to generate types for 29 | */ 30 | export const removeSCSSTypeDefinitionFile = ( 31 | file: string, 32 | options: ConfigOptions 33 | ): void => { 34 | const path = getTypeDefinitionPath(file, options); 35 | removeFile(path); 36 | }; 37 | -------------------------------------------------------------------------------- /lib/core/types.ts: -------------------------------------------------------------------------------- 1 | import { SASSOptions } from "../sass"; 2 | import { ExportType, LogLevel, QuoteType } from "../typescript"; 3 | 4 | type CLIOnlyOptions = Extract; 5 | 6 | export interface CLIOptions extends Exclude { 7 | banner: string; 8 | ignore: string[]; 9 | ignoreInitial: boolean; 10 | exportType: ExportType; 11 | exportTypeName: string; 12 | exportTypeInterface: string; 13 | listDifferent: boolean; 14 | quoteType: QuoteType; 15 | updateStaleOnly: boolean; 16 | watch: boolean; 17 | logLevel: LogLevel; 18 | outputFolder: string | null; 19 | allowArbitraryExtensions: boolean; 20 | } 21 | 22 | export interface ConfigOptions extends CLIOptions, SASSOptions {} 23 | -------------------------------------------------------------------------------- /lib/core/watch.ts: -------------------------------------------------------------------------------- 1 | import chokidar from "chokidar"; 2 | import { alerts } from "./alerts"; 3 | import { listFilesAndPerformSanityChecks } from "./list-files-and-perform-sanity-checks"; 4 | import { removeSCSSTypeDefinitionFile } from "./remove-file"; 5 | import { ConfigOptions } from "./types"; 6 | import { writeFile } from "./write-file"; 7 | 8 | /** 9 | * Watch a file glob and generate the corresponding types. 10 | * 11 | * @param pattern the file pattern to watch for file changes or additions 12 | * @param options the CLI options 13 | */ 14 | export const watch = (pattern: string, options: ConfigOptions): void => { 15 | listFilesAndPerformSanityChecks(pattern, options); 16 | 17 | alerts.success("Watching files..."); 18 | 19 | chokidar 20 | .watch(pattern, { 21 | ignoreInitial: options.ignoreInitial, 22 | ignored: options.ignore, 23 | }) 24 | .on("change", (path) => { 25 | alerts.info(`[CHANGED] ${path}`); 26 | // eslint-disable-next-line @typescript-eslint/no-floating-promises 27 | writeFile(path, options); 28 | }) 29 | .on("add", (path) => { 30 | alerts.info(`[ADDED] ${path}`); 31 | // eslint-disable-next-line @typescript-eslint/no-floating-promises 32 | writeFile(path, options); 33 | }) 34 | .on("unlink", (path) => { 35 | alerts.info(`[REMOVED] ${path}`); 36 | removeSCSSTypeDefinitionFile(path, options); 37 | }); 38 | }; 39 | -------------------------------------------------------------------------------- /lib/core/write-file.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { SassError } from "node-sass"; 3 | import path from "path"; 4 | import { fileToClassNames } from "../sass"; 5 | import { 6 | classNamesToTypeDefinitions, 7 | getTypeDefinitionPath, 8 | } from "../typescript"; 9 | import { alerts } from "./alerts"; 10 | import { removeSCSSTypeDefinitionFile } from "./remove-file"; 11 | import { CLIOptions } from "./types"; 12 | 13 | /** 14 | * Given a single file generate the proper types. 15 | * 16 | * @param file the SCSS file to generate types for 17 | * @param options the CLI options 18 | */ 19 | export const writeFile = async ( 20 | file: string, 21 | options: CLIOptions 22 | ): Promise => { 23 | try { 24 | const classNames = await fileToClassNames(file, options); 25 | const typeDefinition = await classNamesToTypeDefinitions({ 26 | classNames, 27 | file, 28 | ...options, 29 | }); 30 | 31 | const typesPath = getTypeDefinitionPath(file, options); 32 | const typesExist = fs.existsSync(typesPath); 33 | 34 | // Avoid outputting empty type definition files. 35 | // If the file exists and the type definition is now empty, remove the file. 36 | if (!typeDefinition) { 37 | if (typesExist) { 38 | removeSCSSTypeDefinitionFile(file, options); 39 | } else { 40 | alerts.notice(`[NO GENERATED TYPES] ${file}`); 41 | } 42 | return; 43 | } 44 | 45 | // Avoid re-writing the file if it hasn't changed. 46 | // First by checking the file modification time, then 47 | // by comparing the file contents. 48 | if (options.updateStaleOnly && typesExist) { 49 | const fileModified = fs.statSync(file).mtime; 50 | const typeDefinitionModified = fs.statSync(typesPath).mtime; 51 | 52 | if (fileModified < typeDefinitionModified) { 53 | return; 54 | } 55 | 56 | const existingTypeDefinition = fs.readFileSync(typesPath, "utf8"); 57 | if (existingTypeDefinition === typeDefinition) { 58 | return; 59 | } 60 | } 61 | 62 | // Files can be written to arbitrary directories and need to 63 | // be nested to match the project structure so it's possible 64 | // there are multiple directories that need to be created. 65 | const dirname = path.dirname(typesPath); 66 | if (!fs.existsSync(dirname)) { 67 | fs.mkdirSync(dirname, { recursive: true }); 68 | } 69 | 70 | fs.writeFileSync(typesPath, typeDefinition); 71 | alerts.success(`[GENERATED TYPES] ${typesPath}`); 72 | } catch (error) { 73 | const { message, file, line, column } = error as SassError; 74 | const location = file ? ` (${file}[${line}:${column}])` : ""; 75 | alerts.error(`${message}${location}`); 76 | } 77 | }; 78 | -------------------------------------------------------------------------------- /lib/implementations/index.ts: -------------------------------------------------------------------------------- 1 | import nodeSass from "node-sass"; 2 | import sass from "sass"; 3 | 4 | /** 5 | * A list of all possible SASS package implementations that can be used to 6 | * perform the compilation and parsing of the SASS files. The expectation is 7 | * that they provide a nearly identical API so they can be swapped out but 8 | * all of the same logic can be reused. 9 | */ 10 | export const IMPLEMENTATIONS = ["node-sass", "sass"] as const; 11 | export type Implementations = (typeof IMPLEMENTATIONS)[number]; 12 | 13 | type Implementation = typeof nodeSass | typeof sass; 14 | 15 | /** 16 | * Determine which default implementation to use by checking which packages 17 | * are actually installed and available to use. 18 | * 19 | * @param resolver DO NOT USE - this is unfortunately necessary only for testing. 20 | */ 21 | export const getDefaultImplementation = ( 22 | resolver: RequireResolve = require.resolve 23 | ): Implementations => { 24 | let pkg: Implementations = "node-sass"; 25 | 26 | try { 27 | resolver("node-sass"); 28 | } catch (error) { 29 | try { 30 | resolver("sass"); 31 | pkg = "sass"; 32 | } catch (ignoreError) { 33 | pkg = "node-sass"; 34 | } 35 | } 36 | 37 | return pkg; 38 | }; 39 | 40 | /** 41 | * Retrieve the desired implementation. 42 | * 43 | * @param implementation the desired implementation. 44 | */ 45 | export const getImplementation = ( 46 | implementation?: Implementations 47 | ): Implementation => { 48 | if (implementation === "sass") { 49 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 50 | return require("sass"); 51 | } else { 52 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 53 | return require("node-sass"); 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | export { main as default } from "./main"; 2 | -------------------------------------------------------------------------------- /lib/load.ts: -------------------------------------------------------------------------------- 1 | import { bundleRequire } from "bundle-require"; 2 | import JoyCon from "joycon"; 3 | import path from "path"; 4 | import { alerts, CLIOptions, ConfigOptions } from "./core"; 5 | import { getDefaultImplementation } from "./implementations"; 6 | import { nameFormatDefault } from "./sass"; 7 | import { 8 | bannerTypeDefault, 9 | exportTypeDefault, 10 | exportTypeInterfaceDefault, 11 | exportTypeNameDefault, 12 | logLevelDefault, 13 | quoteTypeDefault, 14 | } from "./typescript"; 15 | 16 | const VALID_CONFIG_FILES = [ 17 | "typed-scss-modules.config.ts", 18 | "typed-scss-modules.config.js", 19 | ]; 20 | const joycon = new JoyCon(); 21 | 22 | /** 23 | * Load a custom config file in the project root directory with any options for this package. 24 | * 25 | * This supports config files in the following formats and order: 26 | * - Named `config` export: `export const config = {}` 27 | * - Default export: `export default {}` 28 | * - `module.exports = {}` 29 | */ 30 | export const loadConfig = async (): Promise< 31 | Record | ConfigOptions 32 | > => { 33 | const CURRENT_WORKING_DIRECTORY = process.cwd(); 34 | 35 | const configPath = await joycon.resolve( 36 | VALID_CONFIG_FILES, 37 | CURRENT_WORKING_DIRECTORY, 38 | path.parse(CURRENT_WORKING_DIRECTORY).root 39 | ); 40 | 41 | if (configPath) { 42 | try { 43 | const configModule = await bundleRequire({ 44 | filepath: configPath, 45 | }); 46 | 47 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 48 | const config: ConfigOptions = 49 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 50 | configModule.mod.config || configModule.mod.default || configModule.mod; 51 | 52 | return config; 53 | } catch (error) { 54 | alerts.error( 55 | // eslint-disable-next-line @typescript-eslint/restrict-template-expressions 56 | `An error occurred loading the config file "${configPath}":\n${error}` 57 | ); 58 | 59 | return {}; 60 | } 61 | } 62 | 63 | return {}; 64 | }; 65 | 66 | // Default values for all options that need defaults. 67 | export const DEFAULT_OPTIONS: CLIOptions = { 68 | nameFormat: [nameFormatDefault], 69 | implementation: getDefaultImplementation(), 70 | exportType: exportTypeDefault, 71 | exportTypeName: exportTypeNameDefault, 72 | exportTypeInterface: exportTypeInterfaceDefault, 73 | watch: false, 74 | ignoreInitial: false, 75 | listDifferent: false, 76 | ignore: [], 77 | quoteType: quoteTypeDefault, 78 | updateStaleOnly: false, 79 | logLevel: logLevelDefault, 80 | banner: bannerTypeDefault, 81 | outputFolder: null, 82 | allowArbitraryExtensions: false, 83 | }; 84 | 85 | const removedUndefinedValues = >( 86 | obj: Obj 87 | ): Obj => { 88 | for (const key in obj) { 89 | if (obj[key] === undefined) { 90 | delete obj[key]; 91 | } 92 | } 93 | 94 | return obj; 95 | }; 96 | 97 | /** 98 | * Given both the CLI and config file options merge into a single options object. 99 | * 100 | * When possible, CLI options will override config file options. 101 | * 102 | * Some options are only available in the config file. For example, a custom function can't 103 | * be easily defined via the CLI so some complex options are only available in the config file. 104 | */ 105 | export const mergeOptions = ( 106 | cliOptions: Partial, 107 | configOptions: Partial 108 | ): ConfigOptions => { 109 | return { 110 | ...DEFAULT_OPTIONS, 111 | ...configOptions, 112 | ...removedUndefinedValues(cliOptions), 113 | }; 114 | }; 115 | -------------------------------------------------------------------------------- /lib/main.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import slash from "slash"; 4 | import { 5 | CLIOptions, 6 | generate, 7 | listDifferent, 8 | setAlertsLogLevel, 9 | watch, 10 | } from "./core"; 11 | import { loadConfig, mergeOptions } from "./load"; 12 | 13 | export const main = async ( 14 | pattern: string, 15 | cliOptions: Partial 16 | ) => { 17 | const configOptions = await loadConfig(); 18 | const options = mergeOptions(cliOptions, configOptions); 19 | 20 | setAlertsLogLevel(options.logLevel); 21 | 22 | // When the provided pattern is a directory construct the proper glob to find 23 | // all .scss files within that directory. Also, add the directory to the 24 | // included paths so any imported with a path relative to the root of the 25 | // project still works as expected without adding many include paths. 26 | if (fs.existsSync(pattern) && fs.lstatSync(pattern).isDirectory()) { 27 | if (Array.isArray(options.includePaths)) { 28 | options.includePaths.push(pattern); 29 | } else { 30 | options.includePaths = [pattern]; 31 | } 32 | 33 | // When the pattern provide is a directory, assume all .scss files within. 34 | pattern = slash(path.resolve(pattern, "**/*.scss")); 35 | } 36 | 37 | if (options.listDifferent) { 38 | await listDifferent(pattern, options); 39 | return; 40 | } 41 | 42 | if (options.watch) { 43 | watch(pattern, options); 44 | } else { 45 | await generate(pattern, options); 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /lib/prettier/can-resolve.ts: -------------------------------------------------------------------------------- 1 | // this is only extracted to a module to mock in testing as require.resolve can't be mocked. 2 | // https://github.com/facebook/jest/issues/9543 3 | export function canResolvePrettier() { 4 | try { 5 | require.resolve("prettier"); 6 | return true; 7 | } catch (_error) { 8 | // cannot resolve prettier 9 | return false; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /lib/prettier/index.ts: -------------------------------------------------------------------------------- 1 | import { format, resolveConfig } from "prettier"; 2 | import { alerts } from "../core"; 3 | import { canResolvePrettier } from "./can-resolve"; 4 | 5 | interface Prettier { 6 | format: typeof format; 7 | resolveConfig: typeof resolveConfig; 8 | } 9 | 10 | const isPrettier = (t: unknown): t is Prettier => 11 | !!t && 12 | typeof t === "object" && 13 | t !== null && 14 | "format" in t && 15 | typeof (t as Prettier).format === "function" && 16 | "resolveConfig" in t && 17 | typeof (t as Prettier).resolveConfig === "function"; 18 | 19 | /** 20 | * Try to load prettier and config from project to format input, 21 | * fall back to input if prettier is not found or failed 22 | * 23 | * @param {file} file 24 | * @param {string} input 25 | */ 26 | export const attemptPrettier = async (file: string, input: string) => { 27 | if (!canResolvePrettier()) { 28 | return input; 29 | } 30 | 31 | // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-assignment 32 | const prettier = require("prettier"); 33 | if (!isPrettier(prettier)) { 34 | // doesn't look like prettier 35 | return input; 36 | } 37 | 38 | try { 39 | const config = await prettier.resolveConfig(file, { 40 | editorconfig: true, 41 | }); 42 | // try to return formatted output 43 | return prettier.format(input, { ...config, parser: "typescript" }); 44 | } catch (error) { 45 | // eslint-disable-next-line @typescript-eslint/restrict-template-expressions 46 | alerts.notice(`Tried using prettier, but failed with error: ${error}`); 47 | } 48 | 49 | // failed to format 50 | return input; 51 | }; 52 | -------------------------------------------------------------------------------- /lib/sass/file-to-class-names.ts: -------------------------------------------------------------------------------- 1 | import { 2 | camelCase, 3 | camelCaseTransformMerge, 4 | paramCase, 5 | snakeCase, 6 | } from "change-case"; 7 | import fs from "fs"; 8 | import { Implementations, getImplementation } from "../implementations"; 9 | import { Aliases, SASSImporterOptions, customImporters } from "./importer"; 10 | import { sourceToClassNames } from "./source-to-class-names"; 11 | 12 | export { Aliases }; 13 | export type ClassName = string; 14 | interface Transformer { 15 | (className: ClassName): string; 16 | } 17 | 18 | const transformersMap = { 19 | camel: (className: ClassName) => 20 | camelCase(className, { transform: camelCaseTransformMerge }), 21 | dashes: (className: ClassName) => 22 | /-/.test(className) ? camelCase(className) : className, 23 | kebab: (className: ClassName) => transformersMap.param(className), 24 | none: (className: ClassName) => className, 25 | param: (className: ClassName) => paramCase(className), 26 | snake: (className: ClassName) => snakeCase(className), 27 | } as const; 28 | 29 | type NameFormatWithTransformer = keyof typeof transformersMap; 30 | const NAME_FORMATS_WITH_TRANSFORMER = Object.keys( 31 | transformersMap 32 | ) as NameFormatWithTransformer[]; 33 | 34 | export const NAME_FORMATS = [...NAME_FORMATS_WITH_TRANSFORMER, "all"] as const; 35 | export type NameFormat = (typeof NAME_FORMATS)[number]; 36 | 37 | export interface SASSOptions extends SASSImporterOptions { 38 | additionalData?: string; 39 | includePaths?: string[]; 40 | nameFormat?: string | string[]; 41 | implementation: Implementations; 42 | } 43 | export const nameFormatDefault: NameFormatWithTransformer = "camel"; 44 | 45 | export const fileToClassNames = async ( 46 | file: string, 47 | { 48 | additionalData, 49 | includePaths = [], 50 | nameFormat: rawNameFormat, 51 | implementation, 52 | aliases, 53 | aliasPrefixes, 54 | importer, 55 | }: SASSOptions = {} as SASSOptions 56 | ) => { 57 | const { renderSync } = getImplementation(implementation); 58 | 59 | const nameFormat = ( 60 | typeof rawNameFormat === "string" ? [rawNameFormat] : rawNameFormat 61 | ) as NameFormat[]; 62 | 63 | const nameFormats: NameFormatWithTransformer[] = nameFormat 64 | ? nameFormat.includes("all") 65 | ? NAME_FORMATS_WITH_TRANSFORMER 66 | : (nameFormat as NameFormatWithTransformer[]) 67 | : [nameFormatDefault]; 68 | 69 | const data = fs.readFileSync(file).toString(); 70 | const result = renderSync({ 71 | file, 72 | data: additionalData ? `${additionalData}\n${data}` : data, 73 | includePaths, 74 | importer: customImporters({ aliases, aliasPrefixes, importer }), 75 | }); 76 | 77 | const classNames = await sourceToClassNames(result.css, file); 78 | const transformers = nameFormats.map((item) => transformersMap[item]); 79 | const transformedClassNames = new Set([]); 80 | classNames.forEach((className: ClassName) => { 81 | transformers.forEach((transformer: Transformer) => { 82 | transformedClassNames.add(transformer(className)); 83 | }); 84 | }); 85 | 86 | return Array.from(transformedClassNames).sort((a, b) => a.localeCompare(b)); 87 | }; 88 | -------------------------------------------------------------------------------- /lib/sass/importer.ts: -------------------------------------------------------------------------------- 1 | import { SyncImporter } from "node-sass"; 2 | import { LegacySyncImporter } from "sass"; 3 | 4 | // Hacky way to merge both dart-sass and node-sass importer definitions. 5 | type Importer = LegacySyncImporter & SyncImporter; 6 | 7 | export { Importer }; 8 | 9 | export interface Aliases { 10 | [index: string]: string; 11 | } 12 | 13 | interface AliasImporterOptions { 14 | aliases: Aliases; 15 | aliasPrefixes: Aliases; 16 | } 17 | 18 | /** 19 | * Construct a SASS importer to create aliases for imports. 20 | */ 21 | export const aliasImporter = 22 | ({ aliases, aliasPrefixes }: AliasImporterOptions): Importer => 23 | (url: string) => { 24 | if (url in aliases) { 25 | const file = aliases[url]; 26 | 27 | return { 28 | file, 29 | }; 30 | } 31 | 32 | const prefixMatch = Object.keys(aliasPrefixes).find((prefix) => 33 | url.startsWith(prefix) 34 | ); 35 | 36 | if (prefixMatch) { 37 | return { 38 | file: aliasPrefixes[prefixMatch] + url.substr(prefixMatch.length), 39 | }; 40 | } 41 | 42 | return null; 43 | }; 44 | 45 | export interface SASSImporterOptions { 46 | aliases?: Aliases; 47 | aliasPrefixes?: Aliases; 48 | importer?: Importer | Importer[]; 49 | } 50 | 51 | /** 52 | * Construct custom SASS importers based on options. 53 | * 54 | * - Given aliases and alias prefix options, add a custom alias importer. 55 | * - Given custom SASS importer(s), append to the list of importers. 56 | */ 57 | export const customImporters = ({ 58 | aliases = {}, 59 | aliasPrefixes = {}, 60 | importer, 61 | }: SASSImporterOptions): Importer[] => { 62 | const importers: Importer[] = [aliasImporter({ aliases, aliasPrefixes })]; 63 | 64 | if (typeof importer === "function") { 65 | importers.push(importer); 66 | } else if (Array.isArray(importer)) { 67 | importers.push(...importer); 68 | } 69 | 70 | return importers; 71 | }; 72 | -------------------------------------------------------------------------------- /lib/sass/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | Aliases, 3 | NAME_FORMATS, 4 | SASSOptions, 5 | fileToClassNames, 6 | nameFormatDefault, 7 | } from "./file-to-class-names"; 8 | -------------------------------------------------------------------------------- /lib/sass/source-to-class-names.ts: -------------------------------------------------------------------------------- 1 | import postcss from "postcss"; 2 | import PostcssModulesPlugin from "postcss-modules"; 3 | 4 | /** 5 | * Converts a CSS source string to a list of exports (class names, keyframes, etc.) 6 | */ 7 | export const sourceToClassNames = async ( 8 | source: { toString(): string }, 9 | file: string 10 | ) => { 11 | let result: Record = {}; 12 | await postcss([ 13 | PostcssModulesPlugin({ 14 | getJSON: (_, json) => { 15 | result = json; 16 | }, 17 | }), 18 | ]).process(source, { from: file }); 19 | return Object.keys(result); 20 | }; 21 | -------------------------------------------------------------------------------- /lib/typescript/class-names-to-type-definition.ts: -------------------------------------------------------------------------------- 1 | import { ClassName } from "lib/sass/file-to-class-names"; 2 | import os from "os"; 3 | import reserved from "reserved-words"; 4 | import { alerts } from "../core"; 5 | import { attemptPrettier } from "../prettier"; 6 | 7 | export type ExportType = "named" | "default"; 8 | export const EXPORT_TYPES: ExportType[] = ["named", "default"]; 9 | 10 | export type QuoteType = "single" | "double"; 11 | export const QUOTE_TYPES: QuoteType[] = ["single", "double"]; 12 | 13 | export interface TypeDefinitionOptions { 14 | banner: string; 15 | classNames: ClassName[]; 16 | file: string; 17 | exportType: ExportType; 18 | exportTypeName?: string; 19 | exportTypeInterface?: string; 20 | quoteType?: QuoteType; 21 | } 22 | 23 | export const exportTypeDefault: ExportType = "named"; 24 | export const exportTypeNameDefault: string = "ClassNames"; 25 | export const exportTypeInterfaceDefault: string = "Styles"; 26 | export const quoteTypeDefault: QuoteType = "single"; 27 | export const bannerTypeDefault: string = ""; 28 | 29 | const classNameToNamedTypeDefinition = (className: ClassName) => 30 | `export declare const ${className}: string;`; 31 | 32 | const classNameToType = (className: ClassName, quoteType: QuoteType) => { 33 | const quote = quoteType === "single" ? "'" : '"'; 34 | return ` ${quote}${className}${quote}: string;`; 35 | }; 36 | 37 | const isReservedKeyword = (className: ClassName) => 38 | reserved.check(className, "es5", true) || 39 | reserved.check(className, "es6", true); 40 | 41 | const isValidName = (className: ClassName) => { 42 | if (isReservedKeyword(className)) { 43 | alerts.warn( 44 | `[SKIPPING] '${className}' is a reserved keyword (consider renaming or using --exportType default).` 45 | ); 46 | return false; 47 | } else if (/-/.test(className)) { 48 | alerts.warn( 49 | `[SKIPPING] '${className}' contains dashes (consider using 'camelCase' or 'dashes' for --nameFormat or using --exportType default).` 50 | ); 51 | return false; 52 | } 53 | 54 | return true; 55 | }; 56 | 57 | export const classNamesToTypeDefinitions = async ( 58 | options: TypeDefinitionOptions 59 | ): Promise => { 60 | if (options.classNames.length) { 61 | const lines: string[] = []; 62 | 63 | const { 64 | exportTypeName: ClassNames = exportTypeNameDefault, 65 | exportTypeInterface: Styles = exportTypeInterfaceDefault, 66 | } = options; 67 | 68 | switch (options.exportType) { 69 | case "default": 70 | if (options.banner) lines.push(options.banner); 71 | 72 | lines.push(`export type ${Styles} = {`); 73 | lines.push( 74 | ...options.classNames.map((className) => 75 | classNameToType(className, options.quoteType || quoteTypeDefault) 76 | ) 77 | ); 78 | lines.push(`};${os.EOL}`); 79 | 80 | lines.push(`export type ${ClassNames} = keyof ${Styles};${os.EOL}`); 81 | lines.push(`declare const styles: ${Styles};${os.EOL}`); 82 | lines.push(`export default styles;`); 83 | 84 | break; 85 | case "named": 86 | if (options.banner) lines.push(options.banner); 87 | 88 | lines.push( 89 | ...options.classNames 90 | .filter(isValidName) 91 | .map(classNameToNamedTypeDefinition) 92 | ); 93 | 94 | break; 95 | } 96 | 97 | if (lines.length) { 98 | const typeDefinition = lines.join(`${os.EOL}`) + `${os.EOL}`; 99 | return await attemptPrettier(options.file, typeDefinition); 100 | } else { 101 | return null; 102 | } 103 | } else { 104 | return null; 105 | } 106 | }; 107 | -------------------------------------------------------------------------------- /lib/typescript/get-type-definition-path.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { ConfigOptions } from "../core"; 3 | 4 | const CURRENT_WORKING_DIRECTORY = process.cwd(); 5 | 6 | /** 7 | * Given a file path to a SCSS file, generate the corresponding type definition 8 | * file path. 9 | * 10 | * @param file the SCSS file path 11 | */ 12 | export const getTypeDefinitionPath = ( 13 | file: string, 14 | options: ConfigOptions 15 | ): string => { 16 | let resolvedPath = file; 17 | 18 | if (options.outputFolder) { 19 | const relativePath = path.relative(CURRENT_WORKING_DIRECTORY, file); 20 | resolvedPath = path.resolve( 21 | CURRENT_WORKING_DIRECTORY, 22 | options.outputFolder, 23 | relativePath 24 | ); 25 | } 26 | 27 | if (options.allowArbitraryExtensions) { 28 | const resolvedDirname = path.dirname(resolvedPath); 29 | // Note: `ext` includes a leading period (e.g. '.scss') 30 | const { name, ext } = path.parse(resolvedPath); 31 | // @see https://www.typescriptlang.org/tsconfig/#allowArbitraryExtensions 32 | return path.join(resolvedDirname, `${name}.d${ext}.ts`); 33 | } else { 34 | return `${resolvedPath}.d.ts`; 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /lib/typescript/index.ts: -------------------------------------------------------------------------------- 1 | export { LOG_LEVELS, LogLevel, logLevelDefault } from "../core/alerts"; 2 | export { 3 | EXPORT_TYPES, 4 | ExportType, 5 | QUOTE_TYPES, 6 | QuoteType, 7 | bannerTypeDefault, 8 | classNamesToTypeDefinitions, 9 | exportTypeDefault, 10 | exportTypeInterfaceDefault, 11 | exportTypeNameDefault, 12 | quoteTypeDefault, 13 | } from "./class-names-to-type-definition"; 14 | export { getTypeDefinitionPath } from "./get-type-definition-path"; 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typed-scss-modules", 3 | "version": "0.0.0", 4 | "description": "TypeScript type definition generator for SCSS CSS Modules", 5 | "main": "dist/lib/index.js", 6 | "types": "dist/lib/index.d.ts", 7 | "author": "Spencer Miskoviak ", 8 | "license": "MIT", 9 | "engines": { 10 | "node": ">=16" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/skovy/typed-scss-modules.git" 15 | }, 16 | "homepage": "https://github.com/skovy/typed-scss-modules.git#readme", 17 | "keywords": [ 18 | "scss", 19 | "css modules", 20 | "cli", 21 | "typescript", 22 | "type generator", 23 | "scss modules" 24 | ], 25 | "files": [ 26 | "dist/lib" 27 | ], 28 | "bin": "./dist/lib/cli.js", 29 | "scripts": { 30 | "test": "jest", 31 | "test:watch": "jest --watch", 32 | "typed-scss-modules": "ts-node ./lib/cli.ts", 33 | "clean": "rm -rf ./dist", 34 | "build": "npm run clean && tsc && chmod +x dist/lib/cli.js", 35 | "prepare": "npm run build && husky install", 36 | "check-types": "tsc --noEmit", 37 | "check-formatting": "prettier --check '**/*.{js,json,css,md,scss,tsx,ts}'", 38 | "check-linting": "eslint .", 39 | "commit": "commit", 40 | "semantic-release": "semantic-release" 41 | }, 42 | "devDependencies": { 43 | "@babel/core": "^7.22.9", 44 | "@babel/preset-env": "^7.22.9", 45 | "@babel/preset-typescript": "^7.22.5", 46 | "@commitlint/cli": "^16.2.1", 47 | "@commitlint/config-conventional": "^16.2.1", 48 | "@commitlint/prompt-cli": "^16.2.1", 49 | "@types/glob": "^7.2.0", 50 | "@types/jest": "^29.5.3", 51 | "@types/node": "^17.0.18", 52 | "@types/node-sass": "^4.11.3", 53 | "@types/prettier": "^2.7.3", 54 | "@types/reserved-words": "^0.1.0", 55 | "@types/sass": "^1.16.0", 56 | "@types/yargs": "^17.0.8", 57 | "@typescript-eslint/eslint-plugin": "^6.3.0", 58 | "@typescript-eslint/parser": "^6.3.0", 59 | "babel-jest": "^29.6.2", 60 | "babel-plugin-transform-import-meta": "^2.2.1", 61 | "eslint": "^8.46.0", 62 | "eslint-plugin-jest": "^27.2.3", 63 | "eslint-plugin-jest-formatting": "^3.1.0", 64 | "eslint-plugin-promise": "^6.1.1", 65 | "husky": "^7.0.4", 66 | "jest": "^29.6.2", 67 | "lint-staged": "^12.3.4", 68 | "node-sass": "7.0.3", 69 | "node-sass-json-importer": "^4.3.0", 70 | "prettier": "^2.8.8", 71 | "prettier-plugin-organize-imports": "^3.2.3", 72 | "sass": "^1.49.7", 73 | "semantic-release": "^17.4.7", 74 | "ts-node": "^10.9.1", 75 | "typescript": "^5.1.6" 76 | }, 77 | "peerDependencies": { 78 | "node-sass": "^7.0.3 || ^8.0.0", 79 | "sass": "^1.49.7" 80 | }, 81 | "peerDependenciesMeta": { 82 | "node-sass": { 83 | "optional": true 84 | }, 85 | "sass": { 86 | "optional": true 87 | } 88 | }, 89 | "dependencies": { 90 | "bundle-require": "^4.0.4", 91 | "chalk": "4.1.2", 92 | "change-case": "^4.1.2", 93 | "chokidar": "^3.5.3", 94 | "esbuild": "^0.17.0", 95 | "glob": "^7.2.0", 96 | "joycon": "^3.1.1", 97 | "postcss": "^8.4.27", 98 | "postcss-modules": "^6.0.0", 99 | "reserved-words": "^0.1.2", 100 | "slash": "^3.0.0", 101 | "yargs": "^17.3.1" 102 | }, 103 | "lint-staged": { 104 | "*.{js,json,css,md,scss,tsx,ts}": [ 105 | "prettier --write" 106 | ] 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "skipLibCheck": true, 8 | "baseUrl": ".", 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "declaration": true, 13 | "outDir": "./dist", 14 | "rootDirs": [".", "examples/output-folder/__generated__"] 15 | } 16 | } 17 | --------------------------------------------------------------------------------