├── .npmignore ├── .eslintignore ├── .prettierignore ├── .prettierrc.yaml ├── .gitignore ├── test ├── jest-setup.js ├── fixtures │ ├── options │ │ ├── module-name.js │ │ ├── relative-module-name.js │ │ ├── import-name.js │ │ ├── strict.js │ │ ├── invalid-module-name.js │ │ └── invalid-import-name.js │ ├── garbage-css │ │ └── invalid-css.js │ ├── inject-global │ │ ├── valid-spaces.js │ │ └── valid-tabs.js │ ├── simple │ │ ├── other-library.js │ │ ├── invalid.js │ │ ├── global.js │ │ ├── parse.js │ │ ├── imports.js │ │ ├── valid.js │ │ ├── identify-styled.js │ │ ├── nesting.js │ │ └── helpers.js │ ├── interpolation-tagging │ │ ├── invalid-tag.js │ │ ├── invalid-custom.js │ │ └── valid.js │ ├── ignore-rule-comments │ │ ├── disable-whole-file.js │ │ ├── mix-in-css-disables.js │ │ ├── use-single-line-comments.js │ │ ├── alternating-disable-enable.js │ │ └── use-single-line-disables.js │ ├── hard │ │ ├── valid-js-comments.js │ │ ├── indentation.js │ │ ├── source-maps.js │ │ └── invalid-indentation.js │ ├── typescript │ │ ├── ts-syntax-no-styled-components.tsx │ │ ├── ts-syntax-valid.ts │ │ ├── ts-syntax-parse-error.ts │ │ ├── ts-syntax-jsx-invalid.tsx │ │ └── ts-syntax-invalid.ts │ ├── interpolations │ │ ├── invalid.js │ │ ├── complex.js │ │ └── valid.js │ └── real-world │ │ ├── LineNumbersReportedAccurate.js │ │ └── Circle.js ├── emptyblock.test.js ├── emptycode.test.js ├── garbage-css.test.js ├── real-world.test.js ├── nofiles.test.js ├── interpolation-tagging.test.js ├── inject-global.test.js ├── interpolations.test.js ├── typescript.test.js ├── hard.test.js ├── ignore-rule-comments.test.js ├── simple.test.js ├── options.test.js └── utils.test.js ├── .editorconfig ├── src ├── parsers │ ├── babylon-parser.js │ └── index.js ├── utils │ ├── result.js │ ├── parse.js │ ├── styled.js │ ├── general.js │ └── tagged-template-literal.js └── index.js ├── .eslintrc.js ├── .travis.yml ├── LICENSE.md ├── .github └── ISSUE_TEMPLATE.md ├── package.json ├── CHANGELOG.md └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | .travis.yml 2 | .eslintrc 3 | .eslintignore 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | test/fixtures/**/*.js 2 | lib/ 3 | coverage/ 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | test/fixtures/**/*.js 2 | lib/ 3 | coverage/ 4 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | printWidth: 100 2 | tabWidth: 2 3 | semi: false 4 | singleQuote: true 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | *.log 4 | npm-debug.log* 5 | .DS_Store 6 | lib 7 | coverage/ 8 | -------------------------------------------------------------------------------- /test/jest-setup.js: -------------------------------------------------------------------------------- 1 | const ASYNC_TEST_TIMEOUT = process.env.CI ? 20 * 1000 : 5 * 1000 2 | jest.setTimeout(ASYNC_TEST_TIMEOUT) 3 | -------------------------------------------------------------------------------- /test/fixtures/options/module-name.js: -------------------------------------------------------------------------------- 1 | import emotion from 'emotion' 2 | 3 | // ⚠️ EMPTY BLOCK ⚠️ 4 | const Button = emotion.div` 5 | 6 | ` 7 | -------------------------------------------------------------------------------- /test/fixtures/garbage-css/invalid-css.js: -------------------------------------------------------------------------------- 1 | import styled from 'stylelint'; 2 | 3 | const Button = styled.div` 4 | This is not actually css 5 | `; 6 | -------------------------------------------------------------------------------- /test/fixtures/options/relative-module-name.js: -------------------------------------------------------------------------------- 1 | import emotion from '../../emotion' 2 | 3 | // ⚠️ EMPTY BLOCK ⚠️ 4 | const Button = emotion.div` 5 | 6 | ` 7 | -------------------------------------------------------------------------------- /test/fixtures/options/import-name.js: -------------------------------------------------------------------------------- 1 | import { notDefault } from 'styled-components' 2 | 3 | // ⚠️ EMPTY BLOCK ⚠️ 4 | const Button = notDefault.div` 5 | 6 | ` 7 | -------------------------------------------------------------------------------- /test/fixtures/inject-global/valid-spaces.js: -------------------------------------------------------------------------------- 1 | import { injectGlobal } from 'styled-components'; 2 | 3 | injectGlobal` 4 | html { 5 | color: blue; 6 | } 7 | `; 8 | -------------------------------------------------------------------------------- /test/fixtures/simple/other-library.js: -------------------------------------------------------------------------------- 1 | import styled from 'some-other-lib' 2 | 3 | // ⚠️ BAD INDENTATION ⚠️ 4 | const Button2 = styled.button` 5 | color: blue; 6 | ` 7 | -------------------------------------------------------------------------------- /test/fixtures/inject-global/valid-tabs.js: -------------------------------------------------------------------------------- 1 | import { injectGlobal } from 'styled-components'; 2 | 3 | // Use tabs 4 | injectGlobal` 5 | html { 6 | color: blue; 7 | } 8 | `; 9 | -------------------------------------------------------------------------------- /test/fixtures/options/strict.js: -------------------------------------------------------------------------------- 1 | import { foo, bar } from 'some-module' 2 | 3 | // EMPTY BLOCK 4 | const Button = foo` 5 | 6 | `; 7 | const Button2 = bar` 8 | 9 | `; 10 | -------------------------------------------------------------------------------- /test/fixtures/interpolation-tagging/invalid-tag.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Button1 = styled.button` 4 | color: red; 5 | ${/* sc-invalid */ 'dummy'} 6 | `; 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_size = 2 6 | end_of_line = lf 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /test/fixtures/interpolation-tagging/invalid-custom.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Button1 = styled.button` 4 | color: red; 5 | ${/* sc-custom no quotes present */ 'dummy'} 6 | `; 7 | -------------------------------------------------------------------------------- /test/fixtures/options/invalid-module-name.js: -------------------------------------------------------------------------------- 1 | import something from 'some-lib' 2 | 3 | 4 | // Empty block, but moduleName isn't set to some-lib 5 | // so shouldn't be an error 6 | const Button = something.div` 7 | 8 | ` 9 | -------------------------------------------------------------------------------- /test/fixtures/options/invalid-import-name.js: -------------------------------------------------------------------------------- 1 | import { something } from 'styled-components' 2 | 3 | 4 | // Empty block, but importName isn't set to `something` 5 | // so shouldn't be an error 6 | const Button = something.div` 7 | 8 | ` 9 | -------------------------------------------------------------------------------- /test/fixtures/simple/invalid.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | // ⚠️ EMPTY BLOCK ⚠️ 4 | const Button = styled.button` 5 | 6 | ` 7 | 8 | // ⚠️ BAD INDENTATION ⚠️ 9 | const Button2 = styled.button` 10 | color: blue; 11 | ` 12 | -------------------------------------------------------------------------------- /test/fixtures/ignore-rule-comments/disable-whole-file.js: -------------------------------------------------------------------------------- 1 | /* stylelint-disable */ 2 | import styled from 'styled-components' 3 | 4 | const Button1 = styled.button` 5 | color: red; 6 | `; 7 | 8 | const Button2 = styled.button` 9 | color: blue; 10 | `; 11 | -------------------------------------------------------------------------------- /test/fixtures/hard/valid-js-comments.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Button1 = styled.div` 4 | color: red; 5 | display: block; 6 | // Some comment 7 | margin: 20px 30px 40px 50px; // comment after line 8 | padding: 20px; 9 | `; 10 | 11 | -------------------------------------------------------------------------------- /test/fixtures/simple/global.js: -------------------------------------------------------------------------------- 1 | // Global variables 2 | 3 | // ⚠️ Wrong indentation ⚠️ 4 | const Button = styled.div` 5 | color: red; 6 | ` 7 | 8 | // ⚠️ Wrong indentation ⚠️ 9 | const animation = keyframes` 10 | 0% { 11 | opacity: 0; 12 | } 13 | ` 14 | 15 | const styles = css` 16 | color: blue; 17 | ` 18 | -------------------------------------------------------------------------------- /test/fixtures/typescript/ts-syntax-no-styled-components.tsx: -------------------------------------------------------------------------------- 1 | export interface IAccordionContainerProps { 2 | className?: string; 3 | borderWidth: number, 4 | onChange?(id: string): void; 5 | } 6 | export interface IAccordionContainerState { 7 | selected: string; 8 | } 9 | 10 | export default (prop:IAccordionContainerProps) => (
Test
); -------------------------------------------------------------------------------- /test/fixtures/ignore-rule-comments/mix-in-css-disables.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | const Button1 = styled.button` 4 | color: red; /* stylelint-disable-line */ 5 | `; 6 | 7 | /* stylelint-disable */ 8 | const Button2 = styled.button` 9 | color: red; 10 | `; 11 | 12 | /* stylelint-enable */ 13 | const Button3 = styled.button` 14 | /* stylelint-disable-next-line */ 15 | color: red; 16 | `; 17 | -------------------------------------------------------------------------------- /test/fixtures/interpolations/invalid.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | const color = 'red' 4 | 5 | // ⚠️ BAD INDENTATION ⚠️ 6 | const Button2 = styled.button.attrs({ 7 | type: 'normal' 8 | })` 9 | display: block; 10 | ${ 11 | props => props.isHovering && interpolatedStyle 12 | } 13 | position: ${ 14 | props => props.position 15 | }; 16 | color: ${color}; 17 | background: blue; 18 | ` 19 | -------------------------------------------------------------------------------- /test/fixtures/simple/parse.js: -------------------------------------------------------------------------------- 1 | // asyncGenerators 2 | async function* test(x) { 3 | // objectRestSpread 4 | const a = {} 5 | x = { x, ...a } 6 | 7 | // dynamicImport 8 | const b = import('./somewhere') 9 | 10 | // optionalCatchBinding 11 | try { 12 | throw 0 13 | } catch { 14 | x = 1 15 | } 16 | 17 | // optionalChaining 18 | const op = x?.a 19 | 20 | // nullishCoalescingOperator 21 | x = x ?? 42 22 | } 23 | -------------------------------------------------------------------------------- /test/fixtures/ignore-rule-comments/use-single-line-comments.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | const Button1 = styled.button` 4 | color: red; 5 | `; 6 | 7 | // stylelint-disable 8 | const Button2 = styled.button` 9 | color: red; 10 | `; 11 | 12 | // stylelint-enable 13 | const Button3 = styled.button` 14 | color: red; 15 | `; 16 | 17 | // stylelint-disable 18 | const Button4 = styled.button` 19 | color: red; 20 | `; 21 | -------------------------------------------------------------------------------- /test/fixtures/ignore-rule-comments/alternating-disable-enable.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | const Button1 = styled.button` 4 | color: red; 5 | `; 6 | 7 | /* stylelint-disable */ 8 | const Button2 = styled.button` 9 | color: red; 10 | `; 11 | 12 | /* stylelint-enable */ 13 | const Button3 = styled.button` 14 | color: red; 15 | `; 16 | 17 | /* stylelint-disable */ 18 | const Button4 = styled.button` 19 | color: red; 20 | `; 21 | -------------------------------------------------------------------------------- /test/fixtures/ignore-rule-comments/use-single-line-disables.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | // stylelint-disable-next-line 4 | const Button1 = styled.button` 5 | color: red; 6 | `; 7 | 8 | /* stylelint-disable */ 9 | const Button2 = styled.button` 10 | color: red; 11 | `; 12 | 13 | /* stylelint-enable */ 14 | // stylelint-disable-next-line 15 | /* stylelint-disable-line */ 16 | const Button3 = styled.button` 17 | color: red; 18 | `; 19 | -------------------------------------------------------------------------------- /src/parsers/babylon-parser.js: -------------------------------------------------------------------------------- 1 | const babylon = require('@babel/parser') 2 | 3 | module.exports = (type, plugins) => input => 4 | babylon.parse(input, { 5 | sourceType: 'module', 6 | plugins: [type].concat( 7 | plugins || [ 8 | 'jsx', 9 | ['decorators', { decoratorsBeforeExport: true }], 10 | 'classProperties', 11 | 'exportExtensions', 12 | 'functionBind', 13 | 'functionSent' 14 | ] 15 | ) 16 | }) 17 | -------------------------------------------------------------------------------- /test/fixtures/simple/imports.js: -------------------------------------------------------------------------------- 1 | import notStyled, { 2 | css as notCss, 3 | keyframes as notKeyframes, 4 | injectGlobal as notInjectGlobal 5 | } from 'styled-components' 6 | 7 | // ⚠️ BAD INDENTATION ⚠️ 8 | const Button2 = notStyled.button` 9 | color: blue; 10 | ` 11 | 12 | const styles = notCss` 13 | color: blue; 14 | ` 15 | 16 | const animation = notKeyframes` 17 | 0% { 18 | opacity: 0; 19 | } 20 | 21 | 100% { 22 | opacity: 1; 23 | } 24 | ` 25 | 26 | notInjectGlobal` 27 | html { 28 | color: blue; 29 | } 30 | ` 31 | -------------------------------------------------------------------------------- /test/fixtures/simple/valid.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | // Normal styled component 4 | const Button = styled.button` 5 | color: blue; 6 | ` 7 | 8 | const Box = styled.div` 9 | color: blue; 10 | ` 11 | 12 | // Tagname notation 13 | const Button2 = styled('button')` 14 | color: red; 15 | ` 16 | 17 | const Box2 = styled('div')` 18 | color: red; 19 | ` 20 | 21 | // Component Notation 22 | const Button3 = styled(Button2)` 23 | color: violet; 24 | ` 25 | 26 | const Box3 = styled(Box2)` 27 | color: violet; 28 | ` 29 | -------------------------------------------------------------------------------- /test/fixtures/real-world/LineNumbersReportedAccurate.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Content = styled.section` 4 | 5 | ${props => props.centered && 6 | ` 7 | text-align: center; 8 | `} 9 | 10 | a { 11 | color: ${({ theme }) => theme.colors.lightBlue}; 12 | font-weight: bold; 13 | transition: 0.3s color ease-in; 14 | text-decoration: none; 15 | } 16 | 17 | strong { 18 | font-weight: 600; 19 | } 20 | ul, 21 | ol { 22 | margin: 0; 23 | } 24 | `; 25 | 26 | export default Content; 27 | -------------------------------------------------------------------------------- /test/fixtures/typescript/ts-syntax-valid.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styled, { keyframes } from 'styled-components'; 3 | 4 | export interface IAccordionContainerProps { 5 | className?: string; 6 | borderWidth: number, 7 | onChange?(id: string): void; 8 | } 9 | export interface IAccordionContainerState { 10 | selected: string; 11 | } 12 | 13 | export const AccordionContainerDiv = styled.div` 14 | border: solid ${(props: IAccordionContainerProps) => props.borderWidth}px; 15 | border-bottom-width: 0; 16 | width: auto; 17 | `; -------------------------------------------------------------------------------- /test/fixtures/typescript/ts-syntax-parse-error.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styled, { keyframes } from 'styled-components'; 3 | 4 | export interface IAccordionContainerProps { 5 | className?: string; 6 | borderWidth: number, 7 | onChange?(id: string): void; 8 | } 9 | export interface IAccordionContainerState { 10 | selected: string; 11 | } 12 | 13 | export const AccordionContainerDiv = styled.div<{ width#: string }>` 14 | border: solid ${(props: IAccordionContainerProps) => props.borderWidth}px; 15 | border-bottom-width: 0; 16 | width: auto; 17 | `; -------------------------------------------------------------------------------- /test/fixtures/simple/identify-styled.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Button1 = styled.div` 4 | bad-selector { 5 | color: red; 6 | } 7 | `; 8 | 9 | const Button2 = styled(Button1)` 10 | bad-selector { 11 | color: red; 12 | } 13 | `; 14 | 15 | const Image1 = styled.img.attrs({ src: 'url' })` 16 | bad-selector { 17 | color: red; 18 | } 19 | `; 20 | 21 | const Image2 = styled(Image1).attrs({ src: 'newUrl' })` 22 | bad-selector { 23 | color: red; 24 | } 25 | `; 26 | 27 | const Image3 = Image2.extend` 28 | bad-selector2 { 29 | color: blue; 30 | } 31 | `; 32 | -------------------------------------------------------------------------------- /test/fixtures/typescript/ts-syntax-jsx-invalid.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | interface Props { 5 | header: React.ReactNode; 6 | body: React.ReactNode; 7 | } 8 | type SelectProps = { items: T[] } 9 | class Select extends React.Component, any> { } 10 | 11 | class MyComponent extends React.Component { 12 | render() { 13 | return
14 | {this.props.header} 15 | {this.props.body} 16 |
; 17 | } 18 | } 19 | 20 | const DecoratedComp = styled(MyComponent)` 21 | border: 2px; 22 | `; -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['airbnb-base', 'prettier'], 3 | plugins: ['prettier'], 4 | env: { 5 | jest: true, 6 | node: true 7 | }, 8 | rules: { 9 | 'prettier/prettier': 1, 10 | 11 | // Repo preferences 12 | 'class-methods-use-this': 0, 13 | 'symbol-description': 0, 14 | 'no-unused-vars': [2, { varsIgnorePattern: '^_+$' }], 15 | 'import/no-extraneous-dependencies': 0, 16 | 'no-confusing-arrow': 0, 17 | 'no-else-return': 0, 18 | 'no-prototype-builtins': 0 19 | }, 20 | parserOptions: { 21 | ecmaVersion: 2018, 22 | sourceType: 'module', 23 | ecmaFeatures: { 24 | jsx: true 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | - "10" 5 | before_install: 6 | - npm i -g greenkeeper-lockfile@1 7 | - npm i -g coveralls 8 | install: 9 | - npm install 10 | before_script: 11 | - greenkeeper-lockfile-update 12 | script: 13 | - npm test 14 | after_script: 15 | - greenkeeper-lockfile-upload 16 | notifications: 17 | email: 18 | on_failure: change 19 | jobs: 20 | include: 21 | - stage: lint 22 | node_js: "8" 23 | script: 24 | - npm run lint:eslint 25 | - npm run lint:prettier 26 | - stage: coverage 27 | node_js: "8" 28 | script: 29 | - npm run test -- --coverage && cat ./coverage/lcov.info | coveralls 30 | -------------------------------------------------------------------------------- /test/fixtures/simple/nesting.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | // Nesting 4 | const Button = styled.button` 5 | > h1 { 6 | color: blue; 7 | } 8 | ` 9 | 10 | // Selectors 11 | const Button2 = styled.button` 12 | color: red; 13 | 14 | &:hover { 15 | color: blue; 16 | } 17 | ` 18 | 19 | // Complex selectors 20 | const Button3 = styled.button` 21 | color: red; 22 | 23 | &:placeholder { 24 | color: blue; 25 | } 26 | 27 | &::-webkit-input-placeholder { 28 | color: blue; 29 | } 30 | ` 31 | 32 | const color = 'red' 33 | 34 | // Selectors + interpolations 35 | const Button4 = styled.button` 36 | color: ${color}; 37 | background: ${color}; 38 | 39 | &:hover { 40 | background-color: ${color}; 41 | } 42 | ` 43 | -------------------------------------------------------------------------------- /test/fixtures/interpolations/complex.js: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components' 2 | 3 | const interpolatedStyle = css` 4 | background-color: gray; 5 | color: gray; 6 | ` 7 | 8 | // Interpolation of chunk 9 | const Div = styled.div` 10 | ${interpolatedStyle} 11 | ` 12 | 13 | // Conditional interpolation of chunk 14 | const Button = styled.button` 15 | ${props => props.isHovering && interpolatedStyle} 16 | ` 17 | 18 | // #110 19 | const Bla = styled.div` 20 | ${Button} { 21 | ${something}: blue; 22 | } 23 | 24 | background: ${bla}; 25 | 26 | ${someValue} 27 | ` 28 | 29 | // Multi-line interpolations 30 | const MultiLineDiv = styled.div` 31 | color: ${ 32 | 33 | 34 | 'red' 35 | }; 36 | 37 | ${ 38 | 39 | 40 | 'long values' 41 | } 42 | `; 43 | -------------------------------------------------------------------------------- /test/fixtures/simple/helpers.js: -------------------------------------------------------------------------------- 1 | import styled, { css, keyframes, injectGlobal } from 'styled-components' 2 | 3 | /** 4 | * Valid 5 | */ 6 | const styles = css` 7 | color: blue; 8 | ` 9 | 10 | const animation = keyframes` 11 | 0% { 12 | opacity: 1; 13 | } 14 | 100% { 15 | opacity: 0; 16 | } 17 | ` 18 | 19 | const Button = styled.button` 20 | ${styles} 21 | animation: 3s ${animation}; 22 | ` 23 | 24 | injectGlobal` 25 | html { 26 | margin: 0; 27 | padding: 0; 28 | } 29 | ` 30 | 31 | // ⚠ Indentation 32 | const styles2 = css` 33 | color: blue; 34 | ` 35 | 36 | // ⚠ Indentation 37 | const animation2 = keyframes` 38 | 0% { 39 | opacity: 1; 40 | } 41 | 100% { 42 | opacity: 0; 43 | } 44 | ` 45 | 46 | // ⚠ Indentation 47 | injectGlobal` 48 | html { 49 | margin: 0; 50 | padding: 0; 51 | } 52 | ` 53 | -------------------------------------------------------------------------------- /test/fixtures/hard/indentation.js: -------------------------------------------------------------------------------- 1 | import styled, { keyframes, css } from 'styled-components' 2 | 3 | // None of the below should throw indentation errors 4 | const Comp = () => { 5 | const Button = styled.button` 6 | color: blue; 7 | ` 8 | 9 | return Button 10 | } 11 | 12 | const Comp2 = () => { 13 | const InnerComp = () => { 14 | const Button = styled.button` 15 | color: blue; 16 | ` 17 | 18 | return Button 19 | } 20 | 21 | return InnerComp() 22 | } 23 | 24 | const Button = styled.button`color: blue;` 25 | 26 | const animations = { 27 | spinnerCircle: keyframes` 28 | 0% { 29 | opacity: 0; 30 | } 31 | 32 | 100% { 33 | opacity: 1; 34 | } 35 | ` 36 | } 37 | 38 | const helper = condition => { 39 | if (condition) { 40 | return css` 41 | color: red; 42 | 43 | &:hover { 44 | color: blue; 45 | } 46 | ` 47 | } 48 | return null 49 | } 50 | -------------------------------------------------------------------------------- /test/fixtures/hard/source-maps.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | // ⚠️ BAD INDENTATION at 5:1 ⚠️ 4 | const Button = styled.button` 5 | color: blue; 6 | ` 7 | 8 | // Correct example 9 | const Button2 = styled.button` 10 | color: blue; 11 | ` 12 | 13 | // ⚠️ BAD INDENTATION at 10:5 ⚠️ 14 | const Button3 = styled.button` 15 | color: blue; 16 | ` 17 | 18 | // ⚠️ BAD INDENTATION at 22:5 ⚠️ 19 | const Button4 = styled.button` 20 | color: blue; 21 | background: ${color}; 22 | display: block; 23 | ` 24 | 25 | // ⚠️ BAD INDENTATION at 28:5 ⚠️ 26 | const Button5 = styled.button` 27 | color: blue; 28 | background: ${color}; 29 | display: block; 30 | ` 31 | 32 | // ⚠️ BAD INDENTATION at 35:5 ⚠️ 33 | const Button6 = styled.button` 34 | color: blue; 35 | ${` 36 | background: red; 37 | `} 38 | display: block; 39 | ` 40 | 41 | // ⚠️ UNKNOWN PROPERTY at 42:31 ⚠️ 42 | const Button7 = styled.button`unknown: blue;` 43 | -------------------------------------------------------------------------------- /test/fixtures/interpolation-tagging/valid.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | // Test block 4 | const Button1 = styled.button` 5 | color: red; 6 | ${/* sc-block */ 'dummy'} 7 | `; 8 | 9 | // Test selector 10 | const Button2 = styled.button` 11 | color: red; 12 | ${/* sc-selector */ ':hover'} { 13 | background-color: blue; 14 | } 15 | ${/* sc-selector */ ':active'} { 16 | background-color: green; 17 | } 18 | `; 19 | 20 | // Test declaration 21 | const Button3 = styled.button` 22 | color: red; 23 | ${/* sc-declaration */ 'dummy'} 24 | ${/* sc-declaration */ 'dummy2'} 25 | `; 26 | 27 | // Test property 28 | const Button4 = styled.button` 29 | color: red; 30 | ${/* sc-property */ 'background-color'}: blue; 31 | `; 32 | 33 | // Test value 34 | const Button5 = styled.button` 35 | color: red; 36 | background-color: ${/* sc-value */ 'blue'}; 37 | `; 38 | 39 | // Test custom 40 | const bool = true; 41 | const Button6 = styled.button` 42 | color: red; 43 | margin-${/* sc-custom 'left' */ bool ? 'left' : 'right'}: 10px; 44 | `; 45 | -------------------------------------------------------------------------------- /test/fixtures/typescript/ts-syntax-invalid.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styled, { keyframes } from 'styled-components'; 3 | 4 | export interface IAccordionContainerProps { 5 | className?: string; 6 | borderWidth: number, 7 | onChange?(id: string): void; 8 | } 9 | export interface IAccordionContainerState { 10 | selected: string; 11 | } 12 | 13 | export const AccordionContainerDiv = styled.div` 14 | border: solid ${(props: IAccordionContainerProps) => props.borderWidth}px; 15 | border-bottom-width: 0; 16 | width: auto; 17 | `; 18 | 19 | // ⚠️ EMPTY BLOCK ⚠️ 20 | export const Button = styled.button` 21 | 22 | ` 23 | 24 | // ⚠️ BAD INDENTATION ⚠️ 25 | export const Button2 = styled.button` 26 | 27 | color: blue; 28 | ` 29 | 30 | // ⚠️ BAD INDENTATION ⚠️ (Generic Type) 31 | export const Button3 = styled.button` 32 | 33 | color: blue; 34 | `; 35 | 36 | // ⚠️ BAD INDENTATION ⚠️ (Inline Generic Type) 37 | export const Button4 = styled.button<{ a: boolean; b: string }>` 38 | 39 | color: blue; 40 | `; 41 | 42 | -------------------------------------------------------------------------------- /src/utils/result.js: -------------------------------------------------------------------------------- 1 | exports.isCausedBySubstitution = (warning, line, interpolationLines) => 2 | interpolationLines.some(({ start, end }) => { 3 | if (line > start && line < end) { 4 | // Inner interpolation lines must be 5 | return true 6 | } else if (line === start) { 7 | return ['value-list-max-empty-lines', 'comment-empty-line-before'].indexOf(warning.rule) >= 0 8 | } else if (line === end) { 9 | return ['comment-empty-line-before', 'indentation'].indexOf(warning.rule) >= 0 10 | } else { 11 | return false 12 | } 13 | }) 14 | 15 | exports.getCorrectColumn = (taggedTemplateLocs, line, column) => { 16 | let c = column 17 | 18 | // Not consider multiple tagged literals exsit in the same line, 19 | // so we only add column offset of the first one 20 | taggedTemplateLocs.some(loc => { 21 | if (line === loc.start.line) { 22 | // Start column contains the back quote, so we need inscrese 1 23 | c += loc.start.column + 1 - loc.wrappedOffset 24 | return true 25 | } 26 | return false 27 | }) 28 | 29 | return c 30 | } 31 | -------------------------------------------------------------------------------- /test/emptyblock.test.js: -------------------------------------------------------------------------------- 1 | const stylelint = require('stylelint') 2 | const path = require('path') 3 | 4 | const processor = path.join(__dirname, '../src/index.js') 5 | const rules = { 6 | 'block-no-empty': true 7 | } 8 | const code = `import styled from 'styled-components'; 9 | const DangerButton = styled(Button)\`\`; 10 | ` 11 | 12 | describe('empty block', () => { 13 | let data 14 | 15 | beforeAll(done => { 16 | stylelint 17 | .lint({ 18 | code, 19 | config: { 20 | processors: [processor], 21 | rules 22 | } 23 | }) 24 | .then(result => { 25 | data = result 26 | done() 27 | }) 28 | .catch(err => { 29 | data = err 30 | done() 31 | }) 32 | }) 33 | 34 | it('should have one result', () => { 35 | expect(data.results.length).toEqual(1) 36 | }) 37 | 38 | it('should not have errored', () => { 39 | expect(data.errored).toEqual(false) 40 | }) 41 | 42 | it('should not have any warnings', () => { 43 | expect(data.results[0].warnings.length).toEqual(0) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /test/emptycode.test.js: -------------------------------------------------------------------------------- 1 | const stylelint = require('stylelint') 2 | const path = require('path') 3 | 4 | const processor = path.join(__dirname, '../src/index.js') 5 | const rules = { 6 | 'no-empty-source': true 7 | } 8 | 9 | describe('empty source', () => { 10 | let data 11 | 12 | beforeAll(done => { 13 | stylelint 14 | .lint({ 15 | code: '', 16 | config: { 17 | processors: [processor], 18 | rules 19 | } 20 | }) 21 | .then(result => { 22 | data = result 23 | done() 24 | }) 25 | .catch(err => { 26 | data = err 27 | done() 28 | }) 29 | }) 30 | 31 | it('should have one result', () => { 32 | expect(data.results.length).toEqual(1) 33 | }) 34 | 35 | it('should have errored', () => { 36 | expect(data.errored).toEqual(true) 37 | }) 38 | 39 | it('should have one warning', () => { 40 | expect(data.results[0].warnings.length).toEqual(1) 41 | }) 42 | 43 | it('should have the right line number', () => { 44 | expect(data.results[0].warnings[0].line).toEqual(1) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Maximilian Stoiber 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 | -------------------------------------------------------------------------------- /test/fixtures/real-world/Circle.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled, { keyframes } from 'styled-components' 3 | 4 | // Valid indentation 5 | const animations = { 6 | spinnerCircle: keyframes` 7 | 0% { 8 | opacity: 0; 9 | } 10 | 11 | 100% { 12 | opacity: 1; 13 | } 14 | ` 15 | } 16 | 17 | export default props => { 18 | const CirclePrimitive = styled.div` 19 | width: 100%; 20 | height: 100%; 21 | position: absolute; 22 | left: 0; 23 | top: 0; 24 | ${props.rotate && 25 | ` 26 | -webkit-transform: rotate(${props.rotate}deg); 27 | -ms-transform: rotate(${props.rotate}deg); 28 | transform: rotate(${props.rotate}deg); 29 | `} 30 | 31 | &:before { 32 | content: ''; 33 | display: block; 34 | margin: 0 auto; 35 | width: 15%; 36 | height: 15%; 37 | background-color: #333; 38 | border-radius: 100%; 39 | animation: ${animations.spinnerCircle} 1.2s infinite ease-in-out both; 40 | ${props.delay && 41 | ` 42 | -webkit-animation-delay: ${props.delay}s; 43 | animation-delay: ${props.delay}s; 44 | `} 45 | } 46 | ` 47 | return React.createElement(CirclePrimitive) 48 | } 49 | -------------------------------------------------------------------------------- /src/utils/parse.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Parsing helpers 3 | */ 4 | 5 | const parseImports = (node, currentNames) => { 6 | const names = { ...currentNames } 7 | const imports = node.specifiers.filter( 8 | specifier => specifier.type === 'ImportDefaultSpecifier' || specifier.type === 'ImportSpecifier' 9 | ) 10 | 11 | imports.forEach(singleImport => { 12 | if (singleImport.imported) { 13 | // Is helper method 14 | names[singleImport.imported.name] = singleImport.local.name 15 | } else { 16 | // Is default import 17 | names.default = singleImport.local.name 18 | } 19 | }) 20 | 21 | return names 22 | } 23 | 24 | const getSourceMap = (fullCSS, fragmentCSS, startInSource) => { 25 | const correction = {} 26 | // Save which line in the full CSS is which line in the source 27 | const fullCSSLength = fullCSS.split(/\n/).length 28 | const currentCSSLength = fragmentCSS.split(/\n/).length 29 | const currentCSSStart = fullCSSLength - currentCSSLength + 1 30 | // eslint-disable-next-line no-plusplus 31 | for (let i = 0; i < currentCSSLength + 1; i++) { 32 | correction[currentCSSStart + i] = startInSource + i 33 | } 34 | return correction 35 | } 36 | 37 | exports.parseImports = parseImports 38 | exports.getSourceMap = getSourceMap 39 | -------------------------------------------------------------------------------- /test/fixtures/hard/invalid-indentation.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | // ⚠ 2 indentation errors ⚠ 4 | const Comp = () => { 5 | const Button = styled.button` 6 | color: blue; 7 | background: red; 8 | display: block; 9 | ` 10 | 11 | return Button 12 | } 13 | 14 | // ⚠ 2 indentation errors ⚠ 15 | const Comp2 = () => { 16 | const InnerComp = () => { 17 | const Button = styled.button` 18 | color: blue; 19 | background: red; 20 | display: block; 21 | ` 22 | 23 | return Button 24 | } 25 | 26 | return InnerComp() 27 | } 28 | 29 | // The below don't follow our specifications of keeping closing backtick on base indentation level 30 | // ⚠ 3 indentation errors ⚠ 31 | const Comp3 = () => { 32 | const InnerComp = () => { 33 | const Button = styled.button` 34 | color: blue; 35 | background: red; 36 | display: block; 37 | ` 38 | 39 | return Button 40 | } 41 | 42 | return InnerComp() 43 | } 44 | 45 | // ⚠ 3 indentation errors ⚠ 46 | const Comp4 = () => { 47 | const InnerComp = () => { 48 | const Button = styled.button` 49 | color: blue; 50 | background: red; 51 | display: block;` 52 | 53 | return Button 54 | } 55 | 56 | return InnerComp() 57 | } 58 | 59 | // ⚠ 3 indentation errors ⚠ 60 | const Comp5 = () => { 61 | const InnerComp = () => { 62 | const Button = styled.button` 63 | color: blue; 64 | background: red; 65 | display: block; 66 | ` 67 | 68 | return Button 69 | } 70 | 71 | return InnerComp() 72 | } 73 | -------------------------------------------------------------------------------- /test/garbage-css.test.js: -------------------------------------------------------------------------------- 1 | const stylelint = require('stylelint') 2 | const path = require('path') 3 | const slash = require('slash') 4 | 5 | const processor = path.join(__dirname, '../src/index.js') 6 | const rules = { 7 | 'block-no-empty': true, 8 | 'declaration-block-no-duplicate-properties': true, 9 | indentation: 2 10 | } 11 | 12 | describe('interpolation-tagging', () => { 13 | let fixture 14 | let data 15 | 16 | beforeEach(done => { 17 | stylelint 18 | .lint({ 19 | files: [fixture], 20 | config: { 21 | processors: [processor], 22 | rules 23 | } 24 | }) 25 | .then(result => { 26 | data = result 27 | done() 28 | }) 29 | .catch(err => { 30 | data = err 31 | done() 32 | }) 33 | }) 34 | 35 | describe('valid', () => { 36 | beforeAll(() => { 37 | fixture = slash(path.join(__dirname, './fixtures/garbage-css/invalid-css.js')) 38 | }) 39 | 40 | it('should have one result', () => { 41 | expect(data.results.length).toEqual(1) 42 | }) 43 | 44 | it('should use the right file', () => { 45 | expect(slash(data.results[0].source)).toEqual(fixture) 46 | }) 47 | 48 | it('should have errored', () => { 49 | expect(data.errored).toEqual(true) 50 | }) 51 | 52 | it('should not have exactly one warning', () => { 53 | expect(data.results[0].warnings.length).toEqual(1) 54 | }) 55 | 56 | it('should be a CssSyntaxError', () => { 57 | const warning = data.results[0].warnings[0] 58 | expect(warning.severity).toBe('error') 59 | expect(warning.rule).toBe('CssSyntaxError') 60 | expect(warning.text).toMatch('Unknown word') 61 | }) 62 | 63 | it('should apply sourcemaps correctly', () => { 64 | const warning = data.results[0].warnings[0] 65 | expect(warning.line).toBe(4) 66 | expect(warning.column).toBe(3) 67 | }) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 29 | 30 | 31 | ## Environment 32 | 33 | `npx envinfo --system --binaries --npmPackages stylelint,styled-components,stylelint-processor-styled-components,stylelint-config-styled-components,stylelint-config-recommended --markdown --clipboard` 34 | 35 | 36 | 37 | ## Reproduction 38 | 39 | 40 | https://www.webpackbin.com/bins/-KeeZCr0xKfutOfOujxN 41 | https://codesandbox.io/s/rkmNRByE4 42 | 43 | ## Steps to reproduce 44 | 45 | ## Expected Behavior 46 | 47 | ## Actual Behavior 48 | -------------------------------------------------------------------------------- /test/real-world.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Real world failures 3 | */ 4 | 5 | const stylelint = require('stylelint') 6 | const path = require('path') 7 | const slash = require('slash') 8 | 9 | const processor = path.join(__dirname, '../src/index.js') 10 | const rules = { 11 | 'rule-empty-line-before': [ 12 | 'always-multi-line', 13 | { 14 | except: ['first-nested'], 15 | ignore: ['after-comment'] 16 | } 17 | ], 18 | 'block-no-empty': true, 19 | indentation: 2 20 | } 21 | 22 | describe('real world failures', () => { 23 | let fixture 24 | let data 25 | 26 | // NOTE beforeEach() runs _after_ the beforeAll() hooks of the describe() blocks, so `fixture` 27 | // will have the right path 28 | beforeEach(done => { 29 | stylelint 30 | .lint({ 31 | files: [fixture], 32 | config: { 33 | processors: [processor], 34 | rules 35 | } 36 | }) 37 | .then(result => { 38 | data = result 39 | done() 40 | }) 41 | .catch(err => { 42 | data = err 43 | done() 44 | }) 45 | }) 46 | 47 | describe('Circle', () => { 48 | beforeAll(() => { 49 | fixture = slash(path.join(__dirname, './fixtures/real-world/Circle.js')) 50 | }) 51 | 52 | it('should have one result', () => { 53 | expect(data.results.length).toEqual(1) 54 | }) 55 | 56 | it('should use the right file', () => { 57 | expect(slash(data.results[0].source)).toEqual(fixture) 58 | }) 59 | 60 | it('should not have errored', () => { 61 | expect(data.errored).toEqual(false) 62 | }) 63 | 64 | it('should not have any warnings', () => { 65 | expect(data.results[0].warnings).toEqual([]) 66 | }) 67 | }) 68 | 69 | describe('Line Numbers Report Correctly', () => { 70 | beforeAll(() => { 71 | fixture = slash(path.join(__dirname, './fixtures/real-world/LineNumbersReportedAccurate.js')) 72 | }) 73 | 74 | it('should have one result', () => { 75 | expect(data.results.length).toEqual(1) 76 | }) 77 | 78 | it('should use the right file', () => { 79 | expect(slash(data.results[0].source)).toEqual(fixture) 80 | }) 81 | 82 | it('should not have errored', () => { 83 | expect(data.errored).toEqual(true) 84 | }) 85 | 86 | it('should identify the line number correctly', () => { 87 | const errorLine = 20 88 | expect(data.results[0].warnings[0].line).toEqual(errorLine) 89 | }) 90 | }) 91 | }) 92 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stylelint-processor-styled-components", 3 | "version": "1.10.0", 4 | "description": "A stylelint processor for styled-components", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "lint:eslint": "eslint .", 8 | "lint:prettier": "prettier --list-different \"./**/*.js\"", 9 | "prettier": "prettier --write \"./**/*.js\"", 10 | "test": "jest" 11 | }, 12 | "jest": { 13 | "testMatch": [ 14 | "/test/*.test.js" 15 | ], 16 | "setupFilesAfterEnv": [ 17 | "./test/jest-setup.js" 18 | ] 19 | }, 20 | "husky": { 21 | "hooks": { 22 | "pre-commit": "lint-staged" 23 | } 24 | }, 25 | "lint-staged": { 26 | "**/*.js": [ 27 | "npm run lint:eslint --fix", 28 | "npm run lint:prettier --write" 29 | ] 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "git+https://github.com/styled-components/stylelint-processor-styled-components.git" 34 | }, 35 | "keywords": [ 36 | "stylelint", 37 | "processor", 38 | "stylelint-processor", 39 | "styled-components", 40 | "lint" 41 | ], 42 | "author": "Max Stoiber", 43 | "license": "MIT", 44 | "bugs": { 45 | "url": "https://github.com/styled-components/stylelint-processor-styled-components/issues" 46 | }, 47 | "homepage": "https://github.com/styled-components/stylelint-processor-styled-components#readme", 48 | "greenkeeper": { 49 | "ignore": [ 50 | "eslint", 51 | "eslint-plugin-import", 52 | "eslint-config-airbnb-base", 53 | "husky", 54 | "jest", 55 | "lint-staged", 56 | "lodash", 57 | "prettier" 58 | ] 59 | }, 60 | "devDependencies": { 61 | "@babel/core": "^7.8.3", 62 | "babel-core": "^7.0.0-bridge.0", 63 | "eslint": "^6.8.0", 64 | "eslint-config-airbnb-base": "^14.0.0", 65 | "eslint-config-prettier": "^6.10.0", 66 | "eslint-plugin-import": "^2.20.0", 67 | "eslint-plugin-prettier": "^3.1.2", 68 | "husky": "^4.2.1", 69 | "jest": "^25.1.0", 70 | "lint-staged": "^10.0.3", 71 | "lodash": "^4.17.15", 72 | "prettier": "^1.19.1", 73 | "slash": "^3.0.0", 74 | "stylelint": "^13.0.0", 75 | "typescript": "~3.7.5" 76 | }, 77 | "peerDependency": { 78 | "stylelint-config-styled-components": "^0.1.1", 79 | "stylelint": ">= 10" 80 | }, 81 | "dependencies": { 82 | "@babel/parser": "^7.8.3", 83 | "@babel/traverse": "^7.8.3", 84 | "micromatch": "^4.0.2", 85 | "postcss": "^7.0.26" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /test/nofiles.test.js: -------------------------------------------------------------------------------- 1 | const stylelint = require('stylelint') 2 | const path = require('path') 3 | 4 | const code = `import styled from 'styled-components'; 5 | const StyledTable = styled(StyledTableBase)\` 6 | color: red; 7 | \`` 8 | const processor = path.join(__dirname, '../src/index.js') 9 | const rules = { 10 | indentation: 2 11 | } 12 | 13 | describe('no files', () => { 14 | let codeFilename 15 | let data 16 | 17 | // NOTE beforeEach() runs _after_ the beforeAll() hooks of the describe() blocks 18 | beforeEach(done => { 19 | stylelint 20 | .lint({ 21 | code, 22 | codeFilename, 23 | config: { 24 | processors: [processor], 25 | rules 26 | } 27 | }) 28 | .then(result => { 29 | data = result 30 | done() 31 | }) 32 | .catch(err => { 33 | // eslint-disable-next-line 34 | console.log(err) 35 | data = err 36 | done() 37 | }) 38 | }) 39 | 40 | describe('no codeFilename', () => { 41 | beforeAll(() => { 42 | codeFilename = undefined 43 | }) 44 | 45 | it('should have one result', () => { 46 | expect(data.results.length).toEqual(1) 47 | }) 48 | 49 | it('should use the right file', () => { 50 | // Every new linting that occurs in beforeEach() will increase the id 51 | expect(data.results[0].source).toEqual('') 52 | }) 53 | 54 | it('should have errored', () => { 55 | expect(data.errored).toEqual(true) 56 | }) 57 | 58 | it('should have one warning', () => { 59 | expect(data.results[0].warnings.length).toEqual(1) 60 | }) 61 | 62 | it('should have the right line number', () => { 63 | expect(data.results[0].warnings[0].line).toEqual(3) 64 | }) 65 | }) 66 | 67 | describe('has codeFilename', () => { 68 | beforeAll(() => { 69 | codeFilename = 'somefile.js' 70 | }) 71 | 72 | it('should have one result', () => { 73 | expect(data.results.length).toEqual(1) 74 | }) 75 | 76 | it('should use the right file', () => { 77 | expect(data.results[0].source).toEqual(path.resolve(process.cwd(), codeFilename)) 78 | }) 79 | 80 | it('should have errored', () => { 81 | expect(data.errored).toEqual(true) 82 | }) 83 | 84 | it('should have one warning', () => { 85 | expect(data.results[0].warnings.length).toEqual(1) 86 | }) 87 | 88 | it('should have the right line number', () => { 89 | expect(data.results[0].warnings[0].line).toEqual(3) 90 | }) 91 | }) 92 | }) 93 | -------------------------------------------------------------------------------- /test/fixtures/interpolations/valid.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | const color = 'red' 4 | 5 | // Normal styled component 6 | const Button = styled.button` 7 | display: block; 8 | color: ${color}; 9 | background: blue; 10 | ` 11 | 12 | const Box = styled.div` 13 | display: block; 14 | color: ${color}; 15 | background: blue; 16 | ` 17 | 18 | // Tagname notation 19 | const Button2 = styled('button')` 20 | display: block; 21 | color: ${color}; 22 | background: blue; 23 | ` 24 | 25 | const Box2 = styled('div')` 26 | display: block; 27 | color: ${color}; 28 | background: blue; 29 | ` 30 | 31 | // Component Notation 32 | const Button3 = styled(Button2)` 33 | display: block; 34 | color: ${color}; 35 | background: blue; 36 | ` 37 | 38 | const Box3 = styled(Box2)` 39 | display: block; 40 | color: ${color}; 41 | background: blue; 42 | ` 43 | 44 | // Multiline 45 | const Button4 = styled.button` 46 | display: block; 47 | ${` 48 | color: blue; 49 | `} 50 | background: blue; 51 | ` 52 | 53 | // Conditional 54 | const cond = true 55 | const Button5 = styled.button` 56 | display: block; 57 | ${cond && 58 | ` 59 | color: blue; 60 | `} 61 | background: blue; 62 | ` 63 | 64 | // Conditional 65 | const cond2 = false 66 | const Button6 = styled.button` 67 | display: block; 68 | ${cond2 && 69 | ` 70 | color: ${cond2}; 71 | `} 72 | background: blue; 73 | ` 74 | 75 | // multi interpolations within a property 76 | const borderWidth = '1px' 77 | const borderStyle = 'solid' 78 | const Button7 = styled.button` 79 | width: 20px; 80 | border: ${borderWidth} ${borderStyle} ${color}; 81 | ` 82 | 83 | // Several interpolation statements in a block 84 | const Button8 = styled.button` 85 | ${`display: block;`} 86 | ${`color: ${color};`} 87 | ${`background: blue;`} 88 | ` 89 | // Simple interpolations in one-line css 90 | const display = 'block' 91 | const colorExpression = 'color: red;' 92 | const Button91 = styled.button` 93 | display: ${display}; ${colorExpression} 94 | ` 95 | 96 | // Complex interpolations in one-line css 97 | const backgroundExpression = 'background: blue;' 98 | const Button92 = styled.button` 99 | display: ${display}; ${colorExpression} ${backgroundExpression} 100 | ` 101 | // Interpolations in nested blocks 102 | const Button10 = styled.button` 103 | span { 104 | ${'display: block;'} 105 | } 106 | `; 107 | 108 | // Interpolations after comments 109 | const Button11 = styled.button` 110 | display: block; /* stylelint-disable-line */ 111 | ${colorExpression} 112 | ` 113 | 114 | const Button12 = styled.button` 115 | display: block; 116 | /* stylelint-disable */ 117 | ${colorExpression} 118 | ` 119 | 120 | const Button13 = styled.button` 121 | display: block; 122 | /* 123 | multiline comment with "*" and "/" 124 | */ 125 | ${colorExpression} 126 | ` 127 | 128 | const Button14 = styled.button` 129 | display: block; 130 | /** 131 | * JSDoc style comment 132 | */ 133 | ${colorExpression} 134 | ` 135 | -------------------------------------------------------------------------------- /test/interpolation-tagging.test.js: -------------------------------------------------------------------------------- 1 | const stylelint = require('stylelint') 2 | const path = require('path') 3 | const slash = require('slash') 4 | 5 | const processor = path.join(__dirname, '../src/index.js') 6 | const rules = { 7 | 'block-no-empty': true, 8 | 'declaration-block-no-duplicate-properties': true, 9 | 'no-duplicate-selectors': true, 10 | indentation: 2 11 | } 12 | 13 | describe('interpolation-tagging', () => { 14 | let fixture 15 | let data 16 | 17 | beforeEach(done => { 18 | stylelint 19 | .lint({ 20 | files: [fixture], 21 | config: { 22 | processors: [processor], 23 | rules 24 | } 25 | }) 26 | .then(result => { 27 | data = result 28 | done() 29 | }) 30 | .catch(err => { 31 | data = err 32 | done() 33 | }) 34 | }) 35 | 36 | describe('valid', () => { 37 | beforeAll(() => { 38 | fixture = slash(path.join(__dirname, './fixtures/interpolation-tagging/valid.js')) 39 | }) 40 | 41 | it('should have one result', () => { 42 | expect(data.results.length).toEqual(1) 43 | }) 44 | 45 | it('should use the right file', () => { 46 | expect(slash(data.results[0].source)).toEqual(fixture) 47 | }) 48 | 49 | it('should not have errored', () => { 50 | expect(data.errored).toEqual(false) 51 | }) 52 | 53 | it('should not have any warnings', () => { 54 | expect(data.results[0].warnings.length).toEqual(0) 55 | }) 56 | }) 57 | 58 | describe('invalid tag', () => { 59 | beforeAll(() => { 60 | fixture = slash(path.join(__dirname, './fixtures/interpolation-tagging/invalid-tag.js')) 61 | }) 62 | 63 | it('should return error', () => { 64 | expect(data.errored).toEqual(true) 65 | }) 66 | 67 | it('should have exactly one warning', () => { 68 | expect(data.results[0].warnings.length).toBe(1) 69 | }) 70 | 71 | it('should throw correct error', () => { 72 | const warning = data.results[0].warnings[0] 73 | expect(warning.line).toBe(5) 74 | expect(warning.column).toBe(4) 75 | expect(warning.severity).toBe('error') 76 | expect(warning.text).toMatch(/Styled Components.*invalid sc- tag/) 77 | }) 78 | }) 79 | 80 | describe('invalid custom tag', () => { 81 | beforeAll(() => { 82 | fixture = slash(path.join(__dirname, './fixtures/interpolation-tagging/invalid-custom.js')) 83 | }) 84 | 85 | it('should return error', () => { 86 | expect(data.errored).toEqual(true) 87 | }) 88 | 89 | it('should have exactly one warning', () => { 90 | expect(data.results[0].warnings.length).toBe(1) 91 | }) 92 | 93 | it('should throw correct error', () => { 94 | const warning = data.results[0].warnings[0] 95 | expect(warning.line).toBe(5) 96 | expect(warning.column).toBe(4) 97 | expect(warning.severity).toBe('error') 98 | expect(warning.text).toMatch(/We were unable to parse.*Styled Components/) 99 | }) 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /src/utils/styled.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const { isTaggedTemplateLiteral } = require('./tagged-template-literal') 3 | 4 | /** 5 | * Check if something is a styled-components import declaration 6 | */ 7 | const isStyledImport = (node, moduleName) => 8 | node.type === 'ImportDeclaration' && path.basename(node.source.value) === moduleName 9 | 10 | /** 11 | * Check if something is a styled shorthand call 12 | * e.g. styled.div`` 13 | * 14 | * TODO Lint that the tagname exists 15 | */ 16 | const isStyledShorthand = (node, styledVariableName) => 17 | // Check that it's an object 18 | node.tag && 19 | node.tag.object && 20 | // Check that the object matches the imported name 21 | node.tag.object.name === styledVariableName && 22 | // Check that a property exists, otherwise it's just styled 23 | // without any call 24 | node.tag.property 25 | 26 | /** 27 | * Check if a node is a styld call 28 | * e.g. styled(Component)`` or styled('tagname')`` 29 | */ 30 | const isStyledCall = (node, styledVariableName) => 31 | // Check that it's a function call 32 | node.tag && 33 | node.tag.callee && 34 | // And that the function name matches the imported name 35 | node.tag.callee.name === styledVariableName 36 | 37 | /** 38 | * Check if it has a .attrs postfix which we in that case handle specially 39 | */ 40 | const hasAttrsCall = node => 41 | // Check that it's a function call 42 | node.tag && 43 | node.tag.callee && 44 | // Check that the last member of the call is attrs 45 | node.tag.callee.property && 46 | node.tag.callee.property.name === 'attrs' 47 | 48 | // We don't need the checks here as they were checked in hasAttrsCall 49 | const getAttrsObject = node => node.tag.callee.object 50 | 51 | /** 52 | * Check if something is a styled component call 53 | */ 54 | const isStyled = (node, styledVariableName) => 55 | isTaggedTemplateLiteral(node) && 56 | (isStyledCall(node, styledVariableName) || isStyledShorthand(node, styledVariableName)) 57 | 58 | /** 59 | * Check if it is a .extend call and we pretty reasonable assume that any TTL that ends 60 | * in a .extend must be a styled components call as there is no way to check if it was 61 | * called on a Styled Component 62 | */ 63 | const isExtendCall = node => node.tag && node.tag.property && node.tag.property.name === 'extend' 64 | 65 | /** 66 | * Check if something is a call to one of our helper methods 67 | * 68 | * Returns either a string (the name of the helper) or false 69 | */ 70 | const isHelper = (node, importedNames) => { 71 | if (!isTaggedTemplateLiteral(node)) return false 72 | let helper 73 | Object.keys(importedNames).forEach(name => { 74 | if (importedNames[name] === node.tag.name) { 75 | helper = name 76 | // eslint-disable-next-line no-useless-return 77 | return 78 | } 79 | }) 80 | return helper || false 81 | } 82 | 83 | exports.isStyledImport = isStyledImport 84 | exports.isStyledShorthand = isStyledShorthand 85 | exports.isStyledCall = isStyledCall 86 | exports.isStyled = isStyled 87 | exports.isHelper = isHelper 88 | exports.hasAttrsCall = hasAttrsCall 89 | exports.getAttrsObject = getAttrsObject 90 | exports.isExtendCall = isExtendCall 91 | -------------------------------------------------------------------------------- /test/inject-global.test.js: -------------------------------------------------------------------------------- 1 | const stylelint = require('stylelint') 2 | const path = require('path') 3 | const slash = require('slash') 4 | 5 | const processor = path.join(__dirname, '../src/index.js') 6 | const rules = { 7 | indentation: 2, 8 | 'selector-max-compound-selectors': 1 9 | } 10 | 11 | describe('inject-global', () => { 12 | describe('using spaces', () => { 13 | let fixture 14 | let data 15 | 16 | // NOTE beforeEach() runs _after_ the beforeAll() hooks of the describe() blocks, so `fixture` 17 | // will have the right path 18 | beforeEach(done => { 19 | stylelint 20 | .lint({ 21 | files: [fixture], 22 | config: { 23 | processors: [processor], 24 | rules 25 | } 26 | }) 27 | .then(result => { 28 | data = result 29 | done() 30 | }) 31 | .catch(err => { 32 | // eslint-disable-next-line 33 | console.log(err) 34 | data = err 35 | done() 36 | }) 37 | }) 38 | 39 | describe('valid fixtures', () => { 40 | beforeAll(() => { 41 | fixture = slash(path.join(__dirname, './fixtures/inject-global/valid-spaces.js')) 42 | }) 43 | 44 | it('should have one result', () => { 45 | expect(data.results.length).toEqual(1) 46 | }) 47 | 48 | it('should use the right file', () => { 49 | expect(slash(data.results[0].source)).toEqual(fixture) 50 | }) 51 | 52 | it('should not have errored', () => { 53 | expect(data.errored).toEqual(false) 54 | }) 55 | 56 | it('should not have any warnings', () => { 57 | expect(data.results[0].warnings.length).toEqual(0) 58 | }) 59 | }) 60 | }) 61 | 62 | describe('using tabs', () => { 63 | let fixture 64 | let data 65 | 66 | // NOTE beforeEach() runs _after_ the beforeAll() hooks of the describe() blocks, so `fixture` 67 | // will have the right path 68 | beforeEach(done => { 69 | stylelint 70 | .lint({ 71 | files: [fixture], 72 | config: { 73 | processors: [processor], 74 | rules: { ...rules, indentation: 'tab' } 75 | } 76 | }) 77 | .then(result => { 78 | data = result 79 | done() 80 | }) 81 | .catch(err => { 82 | // eslint-disable-next-line 83 | console.log(err) 84 | data = err 85 | done() 86 | }) 87 | }) 88 | 89 | describe('valid fixtures', () => { 90 | beforeAll(() => { 91 | fixture = slash(path.join(__dirname, './fixtures/inject-global/valid-tabs.js')) 92 | }) 93 | 94 | it('should have one result', () => { 95 | expect(data.results.length).toEqual(1) 96 | }) 97 | 98 | it('should use the right file', () => { 99 | expect(slash(data.results[0].source)).toEqual(fixture) 100 | }) 101 | 102 | it('should not have errored', () => { 103 | expect(data.errored).toEqual(false) 104 | }) 105 | 106 | it('should not have any warnings', () => { 107 | expect(data.results[0].warnings.length).toEqual(0) 108 | }) 109 | }) 110 | }) 111 | }) 112 | -------------------------------------------------------------------------------- /test/interpolations.test.js: -------------------------------------------------------------------------------- 1 | const stylelint = require('stylelint') 2 | const path = require('path') 3 | const slash = require('slash') 4 | 5 | const processor = path.join(__dirname, '../src/index.js') 6 | const rules = { 7 | 'block-no-empty': true, 8 | 'comment-empty-line-before': 'always', 9 | 'declaration-block-no-duplicate-properties': true, 10 | 'value-list-max-empty-lines': 0, 11 | 'max-empty-lines': 1, 12 | indentation: 2 13 | } 14 | 15 | describe('interpolations', () => { 16 | let fixture 17 | let data 18 | 19 | beforeEach(done => { 20 | stylelint 21 | .lint({ 22 | files: [fixture], 23 | config: { 24 | processors: [processor], 25 | rules 26 | } 27 | }) 28 | .then(result => { 29 | data = result 30 | done() 31 | }) 32 | .catch(err => { 33 | data = err 34 | done() 35 | }) 36 | }) 37 | 38 | describe('valid', () => { 39 | beforeAll(() => { 40 | fixture = slash(path.join(__dirname, './fixtures/interpolations/valid.js')) 41 | }) 42 | 43 | it('should have one result', () => { 44 | expect(data.results.length).toEqual(1) 45 | }) 46 | 47 | it('should use the right file', () => { 48 | expect(slash(data.results[0].source)).toEqual(fixture) 49 | }) 50 | 51 | it('should not have errored', () => { 52 | expect(data.errored).toEqual(false) 53 | }) 54 | 55 | it('should not have any warnings', () => { 56 | expect(data.results[0].warnings.length).toEqual(0) 57 | }) 58 | }) 59 | 60 | describe('invalid interpolations', () => { 61 | beforeAll(() => { 62 | fixture = slash(path.join(__dirname, './fixtures/interpolations/invalid.js')) 63 | }) 64 | 65 | it('should have one result', () => { 66 | expect(data.results.length).toEqual(1) 67 | }) 68 | 69 | it('should use the right file', () => { 70 | expect(slash(data.results[0].source)).toEqual(fixture) 71 | }) 72 | 73 | it('should have errored', () => { 74 | expect(data.errored).toEqual(true) 75 | }) 76 | 77 | it('should have warnings', () => { 78 | expect(data.results[0].warnings.length).toEqual(1) 79 | }) 80 | 81 | it('should have an indentation as the first warning', () => { 82 | expect(data.results[0].warnings[0].rule).toEqual('indentation') 83 | }) 84 | 85 | it('should have the indentation warning in the right line', () => { 86 | expect(data.results[0].warnings[0].line).toEqual(16) 87 | }) 88 | }) 89 | 90 | describe('complex interpolations', () => { 91 | beforeAll(() => { 92 | fixture = slash(path.join(__dirname, './fixtures/interpolations/complex.js')) 93 | }) 94 | 95 | it('should have one result', () => { 96 | expect(data.results.length).toEqual(1) 97 | }) 98 | 99 | it('should use the right file', () => { 100 | expect(slash(data.results[0].source)).toEqual(fixture) 101 | }) 102 | 103 | it('should not have errored', () => { 104 | expect(data.errored).toEqual(false) 105 | }) 106 | 107 | it('should not have any warnings', () => { 108 | expect(data.results[0].warnings.length).toEqual(0) 109 | }) 110 | 111 | it('should not result in a CssSyntaxError', () => { 112 | const warning = data.results[0].warnings[0] && data.results[0].warnings[0].rule 113 | 114 | expect(warning).not.toEqual('CssSyntaxError') 115 | }) 116 | }) 117 | }) 118 | -------------------------------------------------------------------------------- /test/typescript.test.js: -------------------------------------------------------------------------------- 1 | const stylelint = require('stylelint') 2 | const path = require('path') 3 | const slash = require('slash') 4 | 5 | const processor = path.join(__dirname, '../src/index.js') 6 | const rules = { 7 | 'block-no-empty': true, 8 | indentation: 2, 9 | 'rule-empty-line-before': [ 10 | 'always-multi-line', 11 | { 12 | except: ['first-nested'], 13 | ignore: ['after-comment'] 14 | } 15 | ] 16 | } 17 | const doLint = (fixture, done) => 18 | stylelint 19 | .lint({ 20 | files: [fixture], 21 | config: { 22 | processors: [processor], 23 | rules 24 | } 25 | }) 26 | .then(result => result) 27 | .catch(err => { 28 | // eslint-disable-next-line 29 | console.log(err) 30 | done() 31 | return err 32 | }) 33 | 34 | describe('Typescript files, both TS and TSX should parse and report any errors correctly', () => { 35 | it('should parse styled components code in TS files and report correctly the errors encountered', done => { 36 | const fixture = slash(path.join(__dirname, './fixtures/typescript/ts-syntax-invalid.ts')) 37 | doLint(fixture, done).then(data => { 38 | expect(data.results.length).toEqual(1) 39 | expect(data.results[0].warnings.length).toEqual(7) 40 | expect(data.results[0].warnings[0].rule).toEqual('block-no-empty') 41 | expect(data.results[0].warnings[1].rule).toEqual('indentation') 42 | expect(data.results[0].warnings[2].rule).toEqual('indentation') 43 | expect(data.results[0].warnings[3].rule).toEqual('indentation') 44 | expect(data.results[0].warnings[4].rule).toEqual('indentation') 45 | expect(data.results[0].warnings[5].rule).toEqual('indentation') 46 | expect(data.results[0].warnings[6].rule).toEqual('indentation') 47 | done() 48 | }) 49 | }) 50 | 51 | it('should not report errors when there are NOT any in a typescript files', done => { 52 | const fixture = slash(path.join(__dirname, './fixtures/typescript/ts-syntax-valid.ts')) 53 | doLint(fixture, done).then(data => { 54 | expect(data.results.length).toEqual(1) 55 | expect(data.results[0].warnings.length).toEqual(0) 56 | done() 57 | }) 58 | }) 59 | 60 | it('should report errors when parsing typescript files with SyntaxError', done => { 61 | const fixture = slash(path.join(__dirname, './fixtures/typescript/ts-syntax-parse-error.ts')) 62 | doLint(fixture, done).then(data => { 63 | expect(data.results.length).toEqual(1) 64 | expect(data.results[0].parseErrors.length).toEqual(1) 65 | expect(data.results[0].parseErrors[0].rule).toEqual('parseError') 66 | done() 67 | }) 68 | }) 69 | 70 | it('should report errors in TSX files(typescript + JSX)', done => { 71 | const fixture = slash(path.join(__dirname, './fixtures/typescript/ts-syntax-jsx-invalid.tsx')) 72 | doLint(fixture, done).then(data => { 73 | expect(data.results.length).toEqual(1) 74 | expect(data.results[0].warnings.length).toEqual(1) 75 | expect(data.results[0].warnings[0].rule).toEqual('indentation') 76 | done() 77 | }) 78 | }) 79 | 80 | it('should ignore errors raised by Stylelint in files without styled components(no-empty-source)', done => { 81 | const fixture = slash( 82 | path.join(__dirname, './fixtures/typescript/ts-syntax-no-styled-components.tsx') 83 | ) 84 | doLint(fixture, done).then(data => { 85 | expect(data.results.length).toEqual(1) 86 | expect(data.results[0].warnings.length).toEqual(0) 87 | done() 88 | }) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /src/parsers/index.js: -------------------------------------------------------------------------------- 1 | const traverse = require('@babel/traverse').default 2 | 3 | const estreeParse = require('./babylon-parser') 4 | 5 | const { 6 | isStyled, 7 | isHelper, 8 | isStyledImport, 9 | hasAttrsCall, 10 | getAttrsObject, 11 | isExtendCall 12 | } = require('../utils/styled') 13 | const { 14 | wrapSelector, 15 | wrapKeyframes, 16 | fixIndentation, 17 | removeBaseIndentation, 18 | isStylelintComment 19 | } = require('../utils/general') 20 | 21 | const { 22 | getTaggedTemplateLiteralContent: getTTLContent 23 | } = require('../utils/tagged-template-literal.js') 24 | const { parseImports, getSourceMap } = require('../utils/parse') 25 | 26 | const processStyledComponentsFile = (ast, absolutePath, options) => { 27 | const extractedCSS = [] 28 | let ignoreRuleComments = [] 29 | let importedNames = { 30 | default: 'styled', 31 | css: 'css', 32 | keyframes: 'keyframes', 33 | injectGlobal: 'injectGlobal', 34 | createGlobalStyle: 'createGlobalStyle' 35 | } 36 | let sourceMap = {} 37 | const taggedTemplateLocs = [] 38 | const interpolationLines = [] 39 | traverse(ast, { 40 | noScope: true, 41 | enter({ node }) { 42 | if (node.type !== 'Program' && node.leadingComments) { 43 | node.leadingComments.forEach(comment => { 44 | if (isStylelintComment(comment.value)) { 45 | ignoreRuleComments.push(`/*${comment.value}*/`) 46 | } 47 | }) 48 | } 49 | if (isStyledImport(node, options.moduleName)) { 50 | importedNames = parseImports(node) 51 | return 52 | } 53 | const helper = !options.strict 54 | ? isHelper(node, importedNames) 55 | : isHelper(node, [importedNames[options.importName]]) 56 | const processedNode = { ...node } 57 | if (hasAttrsCall(node)) { 58 | processedNode.tag = getAttrsObject(node) 59 | } 60 | if ( 61 | !helper && 62 | !isStyled(processedNode, importedNames[options.importName]) && 63 | !isExtendCall(node) 64 | ) { 65 | return 66 | } 67 | const content = getTTLContent(node, absolutePath) 68 | if (!content) return 69 | 70 | const fixedContent = fixIndentation(content).text 71 | let wrappedContent 72 | switch (helper) { 73 | case 'keyframes': 74 | // wrap it in a @keyframes block 75 | wrappedContent = wrapKeyframes(fixedContent) 76 | break 77 | 78 | case 'injectGlobal': 79 | // Don't wrap it as it goes in global scope, but put it to 80 | // base line to avoid indentation errors 81 | wrappedContent = removeBaseIndentation(fixedContent) 82 | break 83 | 84 | default: 85 | // Wrap it in a dummy selector as this is what Styled Components would do 86 | wrappedContent = wrapSelector(fixedContent) 87 | } 88 | const stylelintCommentsAdded = 89 | ignoreRuleComments.length > 0 90 | ? `${ignoreRuleComments.join('\n')}\n${wrappedContent}` 91 | : wrappedContent 92 | extractedCSS.push(stylelintCommentsAdded) 93 | sourceMap = Object.assign( 94 | sourceMap, 95 | getSourceMap(extractedCSS.join('\n'), wrappedContent, processedNode.quasi.loc.start.line) 96 | ) 97 | // Save `loc` of template literals 98 | taggedTemplateLocs.push({ 99 | wrappedOffset: wrappedContent.indexOf(fixedContent), 100 | start: node.quasi.loc.start 101 | }) 102 | // Save dummy interpolation lines 103 | node.quasi.expressions.forEach((expression, i) => { 104 | interpolationLines.push({ 105 | start: node.quasi.quasis[i].loc.end.line, 106 | end: node.quasi.quasis[i + 1].loc.start.line 107 | }) 108 | }) 109 | 110 | /** 111 | * All queued comments have been added to the file so we don't need to, and actually shouldn't 112 | * add them to the file more than once 113 | */ 114 | ignoreRuleComments = [] 115 | } 116 | }) 117 | 118 | return { 119 | extractedCSS: extractedCSS.join('\n'), 120 | taggedTemplateLocs, 121 | interpolationLines, 122 | sourceMap 123 | } 124 | } 125 | 126 | module.exports = (input, absolutePath, options) => { 127 | const typedParser = 128 | absolutePath.endsWith('.ts') || absolutePath.endsWith('.tsx') ? 'typescript' : 'flow' 129 | const ast = estreeParse(typedParser, options.parserPlugins)(input) 130 | return processStyledComponentsFile(ast, absolutePath, options) 131 | } 132 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const micromatch = require('micromatch') 3 | const parse = require('./parsers/index') 4 | const { isCausedBySubstitution, getCorrectColumn } = require('./utils/result') 5 | 6 | let inputId = 1 7 | 8 | // Make sure that state for particular path will be cleaned before each run 9 | // module may be kept in memory when used with vscode-stylelint 10 | const taggedTemplateLocsMap = {} 11 | const interpolationLinesMap = {} 12 | const sourceMapsCorrections = {} 13 | const errorWasThrown = {} 14 | const DEFAULT_OPTIONS = { 15 | moduleName: 'styled-components', 16 | importName: 'default', 17 | strict: false, 18 | ignoreFiles: [] 19 | } 20 | 21 | const realProcessor = options => ({ 22 | // Get string for stylelint to lint 23 | code(input, filepath) { 24 | let absolutePath 25 | if (filepath) { 26 | absolutePath = path.resolve(process.cwd(), filepath) 27 | } else { 28 | absolutePath = `` 29 | inputId += 1 30 | } 31 | 32 | const fileIsIgnored = micromatch(filepath, options.ignoreFiles).length 33 | 34 | if (fileIsIgnored) return input 35 | 36 | try { 37 | const { extractedCSS, interpolationLines, taggedTemplateLocs, sourceMap } = parse( 38 | input, 39 | absolutePath, 40 | options 41 | ) 42 | // Save `loc` of template literals 43 | taggedTemplateLocsMap[absolutePath] = taggedTemplateLocs 44 | // Save dummy interpolation lines 45 | interpolationLinesMap[absolutePath] = interpolationLines 46 | // Save source location 47 | sourceMapsCorrections[absolutePath] = sourceMap 48 | // Clean saved errors 49 | delete errorWasThrown[absolutePath] 50 | 51 | return extractedCSS 52 | } catch (e) { 53 | // Always save the error 54 | errorWasThrown[absolutePath] = e 55 | // Incorrect interpolations will throw CssSyntaxError and they'll be handled by stylelint 56 | // so we can throw it out but not for others 57 | if (e.name === 'CssSyntaxError') { 58 | throw e 59 | } 60 | return '' 61 | } 62 | }, 63 | // Fix sourcemaps 64 | result(stylelintResult, filepath) { 65 | const err = errorWasThrown[filepath] 66 | const fileIsIgnored = micromatch(filepath, options.ignoreFiles).length 67 | 68 | if (fileIsIgnored) return stylelintResult 69 | 70 | if (err) { 71 | if (err.name === 'CssSyntaxError') { 72 | // We threw an error ourselves, in this case we have already put correct 73 | // line/column numbers so no source maps are needed 74 | // (and would actually break the line numbers) 75 | return stylelintResult 76 | } else { 77 | // For other errors, wrap them into the result 78 | return { 79 | ...stylelintResult, 80 | errored: true, 81 | parseErrors: [ 82 | { 83 | line: err.loc && err.loc.line, 84 | column: err.loc && err.loc.column, 85 | rule: 'parseError', 86 | severity: 'error', 87 | text: `${err.message}` 88 | } 89 | ] 90 | } 91 | } 92 | } 93 | const taggedTemplateLocs = taggedTemplateLocsMap[filepath] || [] 94 | const interpolationLines = interpolationLinesMap[filepath] || [] 95 | const lineCorrection = sourceMapsCorrections[filepath] 96 | const warnings = stylelintResult.warnings 97 | .filter( 98 | warning => 99 | // Filter false-positive warnings generated by interpolations substitution 100 | !isCausedBySubstitution(warning, lineCorrection[warning.line], interpolationLines) 101 | ) 102 | .map(warning => ({ 103 | ...warning, 104 | // Replace "brace" with "backtick" in warnings, e.g. 105 | // "Unexpected empty line before closing backtick" (instead of "brace") 106 | text: warning.text.replace(/brace/, 'backtick'), 107 | line: lineCorrection[warning.line] || warning.line, 108 | column: getCorrectColumn( 109 | taggedTemplateLocs, 110 | lineCorrection[warning.line] || warning.line, 111 | warning.column 112 | ) 113 | })) 114 | 115 | const result = { ...stylelintResult, warnings } 116 | // Undo `errored` if no warnings with error severity any more 117 | if (result.errored && !warnings.some(warning => warning.severity === 'error')) { 118 | delete result.errored 119 | } 120 | return result 121 | } 122 | }) 123 | 124 | module.exports = options => realProcessor({ ...DEFAULT_OPTIONS, ...options }) 125 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 1.10.0 2 | * upgrade @babel/core to ^7.8.3 to support new ECMAScript 2020 features by default, see #290 3 | * upgrade stylelint to 13 and other dependencies/devDependencies 4 | 5 | ### 1.9.0 6 | * upgrade stylelint to 12 7 | 8 | ### 1.8.0 9 | * support new option `ignoreFiles`, which allows to skip files to be linted 10 | 11 | ### 1.7.0 12 | * upgrade stylelint to 10, and drop supports for Node < 8 as well 13 | 14 | ### 1.6.0 15 | * avoid `block-no-empty` error 16 | * support new option `strict`, which allows to only parse `importName` 17 | 18 | ### 1.5.2 19 | * upgrade postcss@7 20 | 21 | ### 1.5.1 22 | * fix column number if it is at the first line of interpolation 23 | * clean module level variables before each running 24 | 25 | ### 1.5.0 26 | * fix line number is undefined if source is empty 27 | * support new option `parserPlugins`, which allows to specify custom babel plugins to parse 28 | 29 | ### 1.4.0 30 | * upgrade babylon parser to @babel/parser 31 | * able to show parse errors to users 32 | * support new option `importName`, which allows to import wrapper components object from other names 33 | * filter redundant warnings introduced by v1.2.0 34 | * more intelligent guessing for interpolations 35 | 36 | ### 1.3.2 37 | * added support for optional chaining to our babylon parser 38 | * various minor development improvements that shouldn't affect the product 39 | 40 | ### 1.3.1 41 | * FIX: Interpolations following multiline comments no longer incorrectly error 42 | * README UPDATE: Recommend the recommended instead of standard syntax and no longer recommend the syntax config option for stylelint 43 | 44 | ### 1.3.0 45 | * FIX: Moved to using babylon's new parser that handles typescript, this will fix the problems we had with interpolation tags not being useable in Typescript 46 | * FIX: We now properly handle applying sourcemaps to CSS syntax errors and displaying correct line numbers for errors, Stylelint v9+ is required for this fix though as the biggest part of the fix was on their end 47 | * Stylelint v9+ was added as a peerdependency due to above reasons, no promises are made with this version if you do not update your Stylelint version 48 | 49 | ### 1.2.2 50 | * FIX: Correctly remove base indentation from injectGlobal when using tabs 51 | 52 | ### 1.2.1 53 | * FIX: Don't throw errors on using processor without filename (with Node.js API / CLI) 54 | 55 | ### 1.2.0 56 | * Substitute multiline interpolations with relevant multiline substitutions so line information about errors should now be correctly displayed 57 | 58 | ### 1.1.1 59 | * FIX: Also catch errors regarding resolving absolute path of file and avoid them bubbling up to Stylelint 60 | 61 | ### 1.1.0 62 | * Add support for Node v4 63 | * Catch errors to avoid them bubbling up to Stylelint (which broke the Stylelint plugin for VSCode) 64 | * upgrade typescript-eslint-parser to 9.0.0 65 | 66 | ### 1.0.0 67 | * added support for interpolation tagging to take care of all interpolation edge cases 68 | * added moduleName option for better support for alternative libraries using a similar API 69 | * moved documentation to https://styled-components.com/docs/tooling#stylelint 70 | 71 | ### 0.4.0 72 | * move typescript to devDependencies 73 | * upgrade typescript-eslint-parser to 7.0.0 74 | * support .extend and .attrs Styled Components attributes 75 | * added shared stylelint config to configure a couple of rules to fit with styled-components’ style of writing CSS 76 | 77 | ### 0.3.0 78 | * fix interpolation following a comment edge case 79 | * rewrote our indentation handling fixing a lot of indentation rule problems 80 | 81 | ### 0.2.2 82 | * upgrade typescript-eslint-parser to 5.0.0 83 | 84 | ### 0.2.1 85 | * fix edge case in interpolations inside a declaration block 86 | 87 | ### 0.2.0 88 | * parse consecutive template literals for a single property 89 | * upgrade devDependencies 90 | * add interpolation linting section to README 91 | * make styled-mixin interpolation substitutes unique 92 | * added support for multiple interpolations in a property 93 | * handles interpolations in one-line css 94 | * support using stylelint-disable outside of tagged template literals 95 | * upgrade stylelint to 8.0.0 96 | * upgrade typescript-eslint-parser to 4.0.0 97 | 98 | ### 0.1.2 99 | * fix: move typescript from dependencies to devdependencies 100 | 101 | ### 0.1.1 102 | * add typescript support 103 | * add newline between components' styles 104 | * use unique name for each wrapped selector 105 | * fix: set stylelint result `errored` to `false` if `warning` does not contain errors 106 | 107 | ### 0.1.0 108 | 109 | * initial release 110 | 111 | ### 0.0.1 - 0.0.4 112 | 113 | * working draft 114 | -------------------------------------------------------------------------------- /src/utils/general.js: -------------------------------------------------------------------------------- 1 | const CssError = require('postcss/lib/css-syntax-error') 2 | // selector count 3 | let count = 0 4 | 5 | /** 6 | * Based on https://github.com/mapbox/stylelint-processor-markdown 7 | * @author @davidtheclark 8 | * @author @sindresorhus 9 | */ 10 | const fixIndentation = str => { 11 | // Get whitespaces 12 | const match = str.match(/^[ \t]*(?=\S|$)/gm) 13 | 14 | // Handle oneline TTL case and empty line etc. 15 | if (!match || match.length <= 1) { 16 | return { 17 | text: str, 18 | indentColumns: 0 19 | } 20 | } 21 | 22 | // We enforce that final backtick should be at base indentation level 23 | const baseIndentationLength = match[match.length - 1].length 24 | // Remove whitespace on empty lines before reindenting 25 | const emptyLinesHandled = str.replace(/^[ \t]$/gm, '') 26 | // Normalize indentation by removing common indent 27 | const re = new RegExp(String.raw`^[ \t]{${baseIndentationLength}}`, 'gm') 28 | const reIndentedString = emptyLinesHandled.replace(re, '') 29 | return { 30 | text: reIndentedString, 31 | indentColumns: baseIndentationLength 32 | } 33 | } 34 | 35 | const removeBaseIndentation = str => { 36 | // Remove empty lines 37 | const emptyLinesRemoved = str.replace(/^[ \t]*$/gm, '') 38 | // Get whitespaces 39 | const match = emptyLinesRemoved.match(/^[ \t]*(?=\S|$)/gm) 40 | 41 | if (!match || match.length <= 1) { 42 | return str.trim() 43 | } 44 | 45 | const indentationLevels = match.map(indent => indent.length).filter(indent => indent > 0) 46 | // Math,min would return infinity if list is empty 47 | if (indentationLevels.length === 0) { 48 | return str 49 | } 50 | const baseIndentationLevel = Math.min(...indentationLevels) 51 | // Remove baseIndentation from string 52 | const regex = new RegExp(String.raw`^[ \t]{${baseIndentationLevel}}`, 'gm') 53 | const baseIndentationRemoved = emptyLinesRemoved.replace(regex, '') 54 | return baseIndentationRemoved 55 | } 56 | 57 | const nextNonWhitespaceChar = text => { 58 | const matches = text.match(/^\s*([^\s])/) 59 | if (matches) { 60 | return matches[1] 61 | } else { 62 | return null 63 | } 64 | } 65 | 66 | const reverseString = str => 67 | str 68 | .split('') 69 | .reverse() 70 | .join('') 71 | 72 | const isLastDeclarationCompleted = text => { 73 | // We disregard all comments in this assessment of declaration completion 74 | const commentsRemoved = text.replace(/\/\*[\s\S]*?\*\//g, '') 75 | const reversedText = reverseString(commentsRemoved) 76 | const lastNonWhitespaceChar = nextNonWhitespaceChar(reversedText) 77 | if ( 78 | lastNonWhitespaceChar === ';' || 79 | lastNonWhitespaceChar === '}' || 80 | lastNonWhitespaceChar === '{' || 81 | lastNonWhitespaceChar === null 82 | ) { 83 | return true 84 | } else { 85 | return false 86 | } 87 | } 88 | 89 | // eslint-disable-next-line no-return-assign 90 | const wrapSelector = content => `.selector${(count += 1)} {${content}}\n` 91 | const wrapKeyframes = content => `@keyframes {${content}}\n` 92 | 93 | /** 94 | * The reason we put a \s before .* in the last part of the regex is to make sure we don't 95 | * match stylelint-disable-line and stylelint-disable-next-line or, for now, any future extensions 96 | * as these line specific disables should not be placed outside a css TTL 97 | */ 98 | const isStylelintComment = comment => /^\s*stylelint-(?:enable|disable)(?:\s.*)?$/.test(comment) 99 | 100 | const extrapolateShortenedCommand = (commands, shortCommand, absolutePath, location) => { 101 | let extrapolatedCommand = null 102 | // We use .some so we can break the loop using return true 103 | commands.some(singleCommand => { 104 | if (singleCommand.substr(0, shortCommand.length) === shortCommand) { 105 | if (extrapolatedCommand === null) { 106 | // This is the first time we found a match 107 | extrapolatedCommand = singleCommand 108 | } else { 109 | // We have already found another command which means this is not a unique short command. 110 | // This will probably never throw, as all our current commands start with different letters 111 | throw new CssError( 112 | 'You shortened a Styled Components interpolation tag ambiguously, add a few more characters to fix this error', 113 | location.line, 114 | location.column, 115 | undefined, 116 | absolutePath 117 | ) 118 | } 119 | } 120 | // continue loop 121 | return false 122 | }) 123 | return extrapolatedCommand 124 | } 125 | 126 | exports.wrapKeyframes = wrapKeyframes 127 | exports.wrapSelector = wrapSelector 128 | exports.fixIndentation = fixIndentation 129 | exports.reverseString = reverseString 130 | exports.nextNonWhitespaceChar = nextNonWhitespaceChar 131 | exports.isLastDeclarationCompleted = isLastDeclarationCompleted 132 | exports.isStylelintComment = isStylelintComment 133 | exports.extrapolateShortenedCommand = extrapolateShortenedCommand 134 | exports.removeBaseIndentation = removeBaseIndentation 135 | -------------------------------------------------------------------------------- /test/hard.test.js: -------------------------------------------------------------------------------- 1 | const stylelint = require('stylelint') 2 | const path = require('path') 3 | const slash = require('slash') 4 | 5 | const processor = path.join(__dirname, '../src/index.js') 6 | const rules = { 7 | 'block-no-empty': true, 8 | indentation: 2, 9 | 'property-no-unknown': true 10 | } 11 | 12 | describe('hard', () => { 13 | let fixture 14 | let data 15 | 16 | // NOTE beforeEach() runs _after_ the beforeAll() hooks of the describe() blocks, so `fixture` 17 | // will have the right path 18 | beforeEach(done => { 19 | stylelint 20 | .lint({ 21 | files: [fixture], 22 | syntax: 'scss', 23 | config: { 24 | processors: [processor], 25 | rules 26 | } 27 | }) 28 | .then(result => { 29 | data = result 30 | done() 31 | }) 32 | .catch(err => { 33 | data = err 34 | done() 35 | }) 36 | }) 37 | 38 | describe('extra indentation', () => { 39 | describe('valid', () => { 40 | beforeAll(() => { 41 | fixture = slash(path.join(__dirname, './fixtures/hard/indentation.js')) 42 | }) 43 | 44 | it('should have one result', () => { 45 | expect(data.results.length).toEqual(1) 46 | }) 47 | 48 | it('should use the right file', () => { 49 | expect(slash(data.results[0].source)).toEqual(fixture) 50 | }) 51 | 52 | it('should not have errored', () => { 53 | expect(data.errored).toEqual(false) 54 | }) 55 | 56 | it('should not have any warnings', () => { 57 | expect(data.results[0].warnings).toEqual([]) 58 | }) 59 | }) 60 | 61 | describe('invalid', () => { 62 | beforeAll(() => { 63 | fixture = slash(path.join(__dirname, './fixtures/hard/invalid-indentation.js')) 64 | }) 65 | 66 | it('should have one result', () => { 67 | expect(data.results.length).toEqual(1) 68 | }) 69 | 70 | it('should use the right file', () => { 71 | expect(slash(data.results[0].source)).toEqual(fixture) 72 | }) 73 | 74 | it('should have errored', () => { 75 | expect(data.errored).toEqual(true) 76 | }) 77 | 78 | it('should have 13 warnings', () => { 79 | expect(data.results[0].warnings.length).toEqual(13) 80 | }) 81 | 82 | it('should all be indentation warnings', () => { 83 | data.results[0].warnings.forEach(warning => { 84 | expect(warning.rule).toEqual('indentation') 85 | }) 86 | }) 87 | }) 88 | }) 89 | 90 | describe('source maps', () => { 91 | beforeAll(() => { 92 | fixture = slash(path.join(__dirname, './fixtures/hard/source-maps.js')) 93 | }) 94 | 95 | it('should have one result', () => { 96 | expect(data.results.length).toEqual(1) 97 | }) 98 | 99 | it('should use the right file', () => { 100 | expect(slash(data.results[0].source)).toEqual(fixture) 101 | }) 102 | 103 | it('should have errored', () => { 104 | expect(data.errored).toEqual(true) 105 | }) 106 | 107 | it('should have six warnings', () => { 108 | expect(data.results[0].warnings.length).toEqual(6) 109 | }) 110 | 111 | it('should have five warnings about indentation', () => { 112 | expect(data.results[0].warnings[0].rule).toEqual('property-no-unknown') 113 | expect(data.results[0].warnings[1].rule).toEqual('indentation') 114 | expect(data.results[0].warnings[2].rule).toEqual('indentation') 115 | expect(data.results[0].warnings[3].rule).toEqual('indentation') 116 | expect(data.results[0].warnings[4].rule).toEqual('indentation') 117 | expect(data.results[0].warnings[5].rule).toEqual('indentation') 118 | }) 119 | 120 | it('should have a warning in line 5', () => { 121 | expect(data.results[0].warnings[1].line).toEqual(5) 122 | }) 123 | 124 | it('should have a warning in line 15', () => { 125 | expect(data.results[0].warnings[2].line).toEqual(15) 126 | }) 127 | 128 | it('should have a warning in line 22', () => { 129 | expect(data.results[0].warnings[3].line).toEqual(22) 130 | }) 131 | 132 | it('should have a warning in line 28', () => { 133 | expect(data.results[0].warnings[4].line).toEqual(28) 134 | }) 135 | 136 | it('should have a warning in line 35', () => { 137 | expect(data.results[0].warnings[5].line).toEqual(35) 138 | }) 139 | 140 | it('should have a warning in line 42, column 31', () => { 141 | expect(data.results[0].warnings[0].line).toEqual(42) 142 | expect(data.results[0].warnings[0].column).toEqual(31) 143 | }) 144 | }) 145 | 146 | describe('js style comments', () => { 147 | describe('valid', () => { 148 | beforeAll(() => { 149 | fixture = slash(path.join(__dirname, './fixtures/hard/valid-js-comments.js')) 150 | }) 151 | 152 | it('should have one result', () => { 153 | expect(data.results.length).toEqual(1) 154 | }) 155 | 156 | it('should use the right file', () => { 157 | expect(slash(data.results[0].source)).toEqual(fixture) 158 | }) 159 | 160 | it('should not have errored', () => { 161 | expect(data.errored).toEqual(false) 162 | }) 163 | 164 | it('should not have any warnings', () => { 165 | expect(data.results[0].warnings).toEqual([]) 166 | }) 167 | }) 168 | }) 169 | }) 170 | -------------------------------------------------------------------------------- /test/ignore-rule-comments.test.js: -------------------------------------------------------------------------------- 1 | const stylelint = require('stylelint') 2 | const path = require('path') 3 | const slash = require('slash') 4 | 5 | const processor = path.join(__dirname, '../src/index.js') 6 | const rules = { 7 | 'color-named': 'never' 8 | } 9 | 10 | describe('ignore rule comments', () => { 11 | let fixture 12 | let data 13 | 14 | beforeEach(done => { 15 | stylelint 16 | .lint({ 17 | files: [fixture], 18 | config: { 19 | processors: [processor], 20 | rules 21 | } 22 | }) 23 | .then(result => { 24 | data = result 25 | done() 26 | }) 27 | .catch(err => { 28 | data = err 29 | done() 30 | }) 31 | }) 32 | 33 | describe('disable-whole-file', () => { 34 | beforeAll(() => { 35 | fixture = slash(path.join(__dirname, './fixtures/ignore-rule-comments/disable-whole-file.js')) 36 | }) 37 | 38 | it('should have one result', () => { 39 | expect(data.results.length).toBe(1) 40 | }) 41 | 42 | it('should use the right file', () => { 43 | expect(slash(data.results[0].source)).toBe(fixture) 44 | }) 45 | 46 | it('should not have errored', () => { 47 | expect(data.errored).toBe(false) 48 | }) 49 | 50 | it('should not have any warnings', () => { 51 | expect(data.results[0].warnings.length).toBe(0) 52 | }) 53 | }) 54 | 55 | describe('alternating-disable-enable', () => { 56 | beforeAll(() => { 57 | fixture = slash( 58 | path.join(__dirname, './fixtures/ignore-rule-comments/alternating-disable-enable.js') 59 | ) 60 | }) 61 | 62 | it('should have one result', () => { 63 | expect(data.results.length).toBe(1) 64 | }) 65 | 66 | it('should use the right file', () => { 67 | expect(slash(data.results[0].source)).toBe(fixture) 68 | }) 69 | 70 | it('should have exactly 2 warnings', () => { 71 | expect(data.results[0].warnings.length).toBe(2) 72 | }) 73 | 74 | it('should error at exactly the correct places', () => { 75 | const { warnings } = data.results[0] 76 | 77 | expect(warnings[0].line).toBe(4) 78 | expect(warnings[0].rule).toBe('color-named') 79 | expect(warnings[0].severity).toBe('error') 80 | 81 | expect(warnings[1].line).toBe(14) 82 | expect(warnings[1].rule).toBe('color-named') 83 | expect(warnings[1].severity).toBe('error') 84 | }) 85 | }) 86 | 87 | describe('use-single-line-comments', () => { 88 | beforeAll(() => { 89 | fixture = slash( 90 | path.join(__dirname, './fixtures/ignore-rule-comments/use-single-line-comments.js') 91 | ) 92 | }) 93 | 94 | it('should have one result', () => { 95 | expect(data.results.length).toBe(1) 96 | }) 97 | 98 | it('should use the right file', () => { 99 | expect(slash(data.results[0].source)).toBe(fixture) 100 | }) 101 | 102 | it('should have exactly 2 warnings', () => { 103 | expect(data.results[0].warnings.length).toBe(2) 104 | }) 105 | 106 | it('should error at exactly the correct places', () => { 107 | const { warnings } = data.results[0] 108 | 109 | expect(warnings[0].line).toBe(4) 110 | expect(warnings[0].rule).toBe('color-named') 111 | expect(warnings[0].severity).toBe('error') 112 | 113 | expect(warnings[1].line).toBe(14) 114 | expect(warnings[1].rule).toBe('color-named') 115 | expect(warnings[1].severity).toBe('error') 116 | }) 117 | }) 118 | 119 | describe('use-single-line-disables', () => { 120 | beforeAll(() => { 121 | fixture = slash( 122 | path.join(__dirname, './fixtures/ignore-rule-comments/use-single-line-disables.js') 123 | ) 124 | }) 125 | 126 | it('should have one result', () => { 127 | expect(data.results.length).toBe(1) 128 | }) 129 | 130 | it('should use the right file', () => { 131 | expect(slash(data.results[0].source)).toBe(fixture) 132 | }) 133 | 134 | it('should have exactly 2 warnings', () => { 135 | expect(data.results[0].warnings.length).toBe(2) 136 | }) 137 | 138 | it('should error at exactly the correct places', () => { 139 | const { warnings } = data.results[0] 140 | 141 | expect(warnings[0].line).toBe(5) 142 | expect(warnings[0].rule).toBe('color-named') 143 | expect(warnings[0].severity).toBe('error') 144 | 145 | expect(warnings[1].line).toBe(17) 146 | expect(warnings[1].rule).toBe('color-named') 147 | expect(warnings[1].severity).toBe('error') 148 | }) 149 | 150 | it('should match css exactly', () => { 151 | // This is mostly to check that the disable comments are inserted as expected 152 | const regex = new RegExp( 153 | '^\\.selector\\d+ {\\n' + 154 | ' color: red;\\n' + 155 | '}\\n' + 156 | '\\n' + 157 | '/\\* stylelint-disable \\*/\\n' + 158 | '\\.selector\\d+ {\\n' + 159 | ' color: red;\\n' + 160 | '}\\n' + 161 | '\\n' + 162 | '/\\* stylelint-enable \\*/\\n' + 163 | '\\.selector\\d+ {\\n' + 164 | ' color: red;\\n' + 165 | '}\\n' + 166 | '$' 167 | ) 168 | // eslint-disable-next-line 169 | const cssOutput = data.results[0]._postcssResult.css 170 | expect(regex.test(cssOutput)).toBe(true) 171 | }) 172 | }) 173 | 174 | describe('mix-in-css-disables', () => { 175 | beforeAll(() => { 176 | fixture = slash( 177 | path.join(__dirname, './fixtures/ignore-rule-comments/mix-in-css-disables.js') 178 | ) 179 | }) 180 | 181 | it('should have one result', () => { 182 | expect(data.results.length).toBe(1) 183 | }) 184 | 185 | it('should use the right file', () => { 186 | expect(slash(data.results[0].source)).toBe(fixture) 187 | }) 188 | 189 | it('should not have errored', () => { 190 | expect(data.errored).toBe(false) 191 | }) 192 | 193 | it('should not have any warnings', () => { 194 | expect(data.results[0].warnings.length).toBe(0) 195 | }) 196 | }) 197 | }) 198 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `stylelint-processor-styled-components` 2 | 3 | Lint your [styled components](https://github.com/styled-components/styled-components) with [stylelint](http://stylelint.io/)! 4 | 5 | [![Build Status][build-badge]][build-url] 6 | [![Coverage Status][coverage-badge]][coverage-url] 7 | [![Join the community on Spectrum](https://withspectrum.github.io/badge/badge.svg)](https://spectrum.chat/styled-components/stylelint-processor) 8 | [![Greenkeeper][greenkeeper-badge]][greenkeeper-url] 9 | 10 | ![Video of project in use](http://imgur.com/br9zdHb.gif) 11 | 12 | ## Setup 13 | 14 | You need: 15 | 16 | - `stylelint` (duh) 17 | - This processor, to extract styles from `styled-components` 18 | - The [`stylelint-config-styled-components`](https://github.com/styled-components/stylelint-config-styled-components) config to disable stylelint rules that clash with `styled-components` 19 | - Your favorite `stylelint` config! (for example [`stylelint-config-recommended`](https://github.com/stylelint/stylelint-config-recommended)) 20 | 21 | ``` 22 | (npm install --save-dev \ 23 | stylelint \ 24 | stylelint-processor-styled-components \ 25 | stylelint-config-styled-components \ 26 | stylelint-config-recommended) 27 | ``` 28 | 29 | Now use those in your `.stylelintrc` and run stylelint with your JavaScript files! 30 | 31 | ```json 32 | { 33 | "processors": ["stylelint-processor-styled-components"], 34 | "extends": [ 35 | "stylelint-config-recommended", 36 | "stylelint-config-styled-components" 37 | ] 38 | } 39 | ``` 40 | 41 | > **NOTE:** The processor works with Flow- and TypeScript-typed files too! (we'll assume TypeScript usage if your files end in `.ts` or `.tsx`) 42 | 43 | And it also has some options. Their default values are, 44 | 45 | ```json 46 | { 47 | "processors": [["stylelint-processor-styled-components", { 48 | "moduleName": "styled-components", 49 | "importName": "default", 50 | "strict": false, 51 | "ignoreFiles": [], 52 | "parserPlugins": [ 53 | "jsx", 54 | ["decorators", { "decoratorsBeforeExport": true }], 55 | "classProperties", 56 | "exportExtensions", 57 | "functionBind", 58 | "functionSent" 59 | ] 60 | }]] 61 | } 62 | ``` 63 | 64 | - Combining with `moduleName`, `importName` and `strict`, you can tell the processor what kinds of tagged template literals to lint. 65 | 66 | ``` 67 | import styled, { css, keyframes } from 'styled-components'; 68 | 69 | // `importName` from `moduleName`, which means where `styled` comes from 70 | styled(Component)``; 71 | styled('div')``; 72 | styled.div``; 73 | 74 | // any other imports from `moduleName` (if `strict` is true, they will not be linted) 75 | css``; 76 | keyframes``; 77 | 78 | // special extend calls, which have been deprecated in styled-components v4 79 | Component.extend``; 80 | 81 | ``` 82 | 83 | - `ignoreFiles` is passed to [micromatch](https://github.com/micromatch/micromatch#api) as the second parameter, which means one or more glob patterns for matching. 84 | 85 | - `parserPlugins` is used to make the processor's parser be able to parse new syntaxes. All available babel parser plugins and related options can be found in [Babel's website](https://babeljs.io/docs/en/babel-parser#plugins). 86 | 87 | ## [Documentation](https://www.styled-components.com/docs/tooling#stylelint) 88 | 89 | **Further documentation for this processor lives on [the styled-components website](https://www.styled-components.com/docs/tooling#stylelint)!** 90 | 91 | - [Setup](https://www.styled-components.com/docs/tooling#setup) 92 | - [Webpack](https://www.styled-components.com/docs/tooling#webpack) 93 | - [Interpolation tagging](https://www.styled-components.com/docs/tooling#interpolation-tagging) 94 | - [Tags](https://www.styled-components.com/docs/tooling#tags) 95 | - [sc-custom](https://www.styled-components.com/docs/tooling#sc-custom) 96 | - [Syntax Notes](https://www.styled-components.com/docs/tooling#syntax-notes) 97 | 98 | ## F.A.Q. 99 | 100 | ### Why does it throw `Unexpected token`? Even thought the file didn't import `styled-components`. 101 | 102 | You can custom babel plugins by `option.parserPlugins` now. An API example is [our test](https://github.com/styled-components/stylelint-processor-styled-components/blob/master/test/options.test.js#L211). But if someone can implement [#231](https://github.com/styled-components/stylelint-processor-styled-components/issues/231), that will be much better. 103 | 104 | If your project includes `yarn.lock` or `package-lock.json`, an alternative cause can be that babel related dependencies, i.e. `@babel/parser` and `@babel/traverse`, are outdated, especially when linting files with new TypeScript syntaxes. You can upgrade them by removing their entries in the lockfile and reinstall dependencies. 105 | 106 | ### Why does it throw unexpected lint errors? 107 | 108 | The processor can not always parse interpolations with right things. But you can use [interpolation-tagging](https://www.styled-components.com/docs/tooling#interpolation-tagging) to help it. If you have ideas to make it more intelligent, feel free to send a PR or share your solution by an new issue. 109 | 110 | What's more, if set `syntax: css-in-js` in stylelint@10, it can extract styles from `styled-components` without this processor. Even though there are still lots of differences with this processor, we hope this processor's abilities can be migrated to stylelint totally in the future. 111 | 112 | ### I don't want specified tagged template literal to be parsed, i.e. `css`. 113 | 114 | You can set `option.strict`. More examples are in [#258](https://github.com/styled-components/stylelint-processor-styled-components/pull/258). 115 | 116 | ## License 117 | 118 | Licensed under the MIT License, Copyright © 2017 Maximilian Stoiber. See [LICENSE.md](./LICENSE.md) for more information! 119 | 120 | Based on Mapbox' excellent [`stylelint-processor-markdown`](https://github.com/mapbox/stylelint-processor-markdown), thanks to @davidtheclark! 121 | 122 | [build-badge]: https://travis-ci.org/styled-components/stylelint-processor-styled-components.svg?branch=master 123 | [build-url]: https://travis-ci.org/styled-components/stylelint-processor-styled-components 124 | [coverage-badge]: https://coveralls.io/repos/github/styled-components/stylelint-processor-styled-components/badge.svg?branch=master 125 | [coverage-url]: https://coveralls.io/github/styled-components/stylelint-processor-styled-components?branch=master 126 | [greenkeeper-badge]: https://badges.greenkeeper.io/styled-components/stylelint-processor-styled-components.svg 127 | [greenkeeper-url]: https://greenkeeper.io/ 128 | -------------------------------------------------------------------------------- /src/utils/tagged-template-literal.js: -------------------------------------------------------------------------------- 1 | const CssError = require('postcss/lib/css-syntax-error') 2 | const { 3 | reverseString, 4 | nextNonWhitespaceChar, 5 | isLastDeclarationCompleted, 6 | extrapolateShortenedCommand 7 | } = require('./general') 8 | 9 | /** 10 | * Check if a node is a tagged template literal 11 | */ 12 | const isTaggedTemplateLiteral = node => node.type === 'TaggedTemplateExpression' 13 | 14 | /** 15 | * Check if a tagged template literal has interpolations 16 | */ 17 | const hasInterpolations = node => !node.quasi.quasis[0].tail 18 | 19 | /** 20 | * Retrieves all the starting and ending comments of a TTL expression 21 | */ 22 | const retrieveStartEndComments = expression => 23 | (expression.leadingComments || []).concat(expression.trailingComments || []) 24 | 25 | /** 26 | * Checks if given comment value is an interpolation tag 27 | */ 28 | const isScTag = comment => /^\s*?sc-[a-z]/.test(comment) 29 | 30 | /** 31 | * Checks if an interpolation has an sc comment tag 32 | */ 33 | const hasInterpolationTag = expression => { 34 | const relevantComments = retrieveStartEndComments(expression).map( 35 | commentObject => commentObject.value 36 | ) 37 | return relevantComments.some(isScTag) 38 | } 39 | 40 | const extractScTagInformation = comment => { 41 | const matchArray = comment.match(/^\s*?sc-([a-z]+)\s*(?:(?:'(.*?)')|(?:"(.*?)"))?\s*$/) 42 | if (matchArray === null) { 43 | return null 44 | } 45 | return { 46 | command: matchArray[1], 47 | // This is only cared about if command is custom 48 | customPlaceholder: matchArray[2] || matchArray[3] 49 | } 50 | } 51 | 52 | const interpolationTagAPI = ['block', 'selector', 'declaration', 'property', 'value', 'custom'] 53 | /** 54 | * Enact the interpolation tagging API 55 | */ 56 | const parseInterpolationTag = (expression, id, absolutePath) => { 57 | const relevantComments = retrieveStartEndComments(expression) 58 | let substitute 59 | relevantComments.some(comment => { 60 | if (isScTag(comment.value)) { 61 | // We always assume that there is only one sc tag in an interpolation 62 | const scTagInformation = extractScTagInformation(comment.value) 63 | if (scTagInformation === null) { 64 | throw new CssError( 65 | 'We were unable to parse your Styled Components interpolation tag, this is most likely due to lack of quotes in an sc-custom tag, refer to the documentation for correct format', 66 | comment.loc.start.line, 67 | comment.loc.start.column, 68 | undefined, 69 | absolutePath 70 | ) 71 | } 72 | scTagInformation.command = extrapolateShortenedCommand( 73 | interpolationTagAPI, 74 | scTagInformation.command, 75 | absolutePath, 76 | comment.loc.start 77 | ) 78 | switch (scTagInformation.command) { 79 | case 'selector': 80 | substitute = `.sc-selector${id}` 81 | break 82 | 83 | case 'block': 84 | case 'declaration': 85 | substitute = `-styled-mixin${id}: dummyValue;` 86 | break 87 | 88 | case 'property': 89 | substitute = `-styled-mixin${id}` 90 | break 91 | 92 | case 'value': 93 | substitute = '$dummyValue' 94 | break 95 | 96 | case 'custom': 97 | substitute = scTagInformation.customPlaceholder 98 | break 99 | 100 | default: 101 | throw new CssError( 102 | 'You tagged a Styled Components interpolation with an invalid sc- tag. Refer to the documentation to see valid interpolation tags', 103 | comment.loc.start.line, 104 | comment.loc.start.column, 105 | undefined, 106 | absolutePath 107 | ) 108 | } 109 | return true // Break loop 110 | } 111 | return false // Continue loop 112 | }) 113 | return substitute 114 | } 115 | 116 | /** 117 | * Merges the interpolations in a parsed tagged template literals with the strings 118 | */ 119 | const interleave = (quasis, expressions, absolutePath) => { 120 | // Used for making sure our dummy mixins are all unique 121 | let count = 0 122 | let css = '' 123 | for (let i = 0, l = expressions.length; i < l; i += 1) { 124 | const prevText = quasis[i].value.raw 125 | const nextText = quasis[i + 1].value.raw 126 | const prevChar = nextNonWhitespaceChar(reverseString(prevText)) 127 | const nextChar = nextNonWhitespaceChar(nextText) 128 | 129 | css += prevText 130 | let substitute 131 | if (hasInterpolationTag(expressions[i])) { 132 | substitute = parseInterpolationTag(expressions[i], count, absolutePath) 133 | count += 1 134 | // No sc tag so we guess defaults 135 | } else if (nextChar === '{') { 136 | // Guess as selector, which shares format with `parseInterpolationTag`, but not `wrapSelector` 137 | substitute = `.sc-selector${count}` 138 | count += 1 139 | } else if (prevChar === ':') { 140 | // After a colon and not a pseudo-class, then guess as value 141 | substitute = '$dummyValue' 142 | } else if (nextChar === ':') { 143 | // Before a colon, then guess as property 144 | substitute = `-styled-mixin${count}` 145 | count += 1 146 | } else if (isLastDeclarationCompleted(css)) { 147 | /** This block assumes that if you put an interpolation in the position 148 | * of the start of a declaration that the interpolation will 149 | * contain a full declaration and not later in the template literal 150 | * be completed by another interpolation / completed by following text 151 | * in the literal 152 | */ 153 | substitute = `-styled-mixin${count}: dummyValue` 154 | count += 1 155 | if (nextNonWhitespaceChar(nextText) !== ';') { 156 | substitute += ';' 157 | } 158 | } else { 159 | /* This block assumes that we are in the middle of a declaration 160 | * and that the interpolation is providing a value, not a property 161 | * or part of a property 162 | */ 163 | substitute = '$dummyValue' 164 | } 165 | // Make sure substituted by same count of lines 166 | const targetLines = quasis[i + 1].loc.start.line - quasis[i].loc.end.line + 1 167 | let currentLines = substitute.split('\n').length 168 | while (currentLines < targetLines) { 169 | substitute += '\n/* dummyComment */' 170 | currentLines += 1 171 | } 172 | 173 | css += substitute 174 | } 175 | css += quasis[quasis.length - 1].value.raw 176 | return css 177 | } 178 | 179 | /** 180 | * Get the content of a tagged template literal 181 | * 182 | * TODO Cover edge cases 183 | */ 184 | const getTaggedTemplateLiteralContent = (node, absolutePath) => { 185 | if (hasInterpolations(node)) { 186 | return interleave(node.quasi.quasis, node.quasi.expressions, absolutePath) 187 | } else { 188 | return node.quasi.quasis[0].value.raw 189 | } 190 | } 191 | 192 | exports.isTaggedTemplateLiteral = isTaggedTemplateLiteral 193 | exports.getTaggedTemplateLiteralContent = getTaggedTemplateLiteralContent 194 | exports.interleave = interleave 195 | exports.hasInterpolationTag = hasInterpolationTag 196 | exports.parseInterpolationTag = parseInterpolationTag 197 | exports.extractScTagInformation = extractScTagInformation 198 | -------------------------------------------------------------------------------- /test/simple.test.js: -------------------------------------------------------------------------------- 1 | const stylelint = require('stylelint') 2 | const path = require('path') 3 | const slash = require('slash') 4 | 5 | const processor = path.join(__dirname, '../src/index.js') 6 | const rules = { 7 | 'block-no-empty': true, 8 | indentation: 2, 9 | 'rule-empty-line-before': [ 10 | 'always-multi-line', 11 | { 12 | except: ['first-nested'], 13 | ignore: ['after-comment'] 14 | } 15 | ], 16 | 'selector-type-no-unknown': true 17 | } 18 | 19 | describe('simple', () => { 20 | let fixture 21 | let data 22 | 23 | // NOTE beforeEach() runs _after_ the beforeAll() hooks of the describe() blocks, so `fixture` 24 | // will have the right path 25 | beforeEach(done => { 26 | stylelint 27 | .lint({ 28 | files: [fixture], 29 | config: { 30 | processors: [processor], 31 | rules 32 | } 33 | }) 34 | .then(result => { 35 | data = result 36 | done() 37 | }) 38 | .catch(err => { 39 | // eslint-disable-next-line 40 | console.log(err) 41 | data = err 42 | done() 43 | }) 44 | }) 45 | 46 | describe('valid fixtures', () => { 47 | beforeAll(() => { 48 | fixture = slash(path.join(__dirname, './fixtures/simple/valid.js')) 49 | }) 50 | 51 | it('should have one result', () => { 52 | expect(data.results.length).toEqual(1) 53 | }) 54 | 55 | it('should use the right file', () => { 56 | expect(slash(data.results[0].source)).toEqual(fixture) 57 | }) 58 | 59 | it('should not have errored', () => { 60 | expect(data.errored).toEqual(false) 61 | }) 62 | 63 | it('should not have any warnings', () => { 64 | expect(data.results[0].warnings.length).toEqual(0) 65 | }) 66 | }) 67 | 68 | describe('invalid fixtures', () => { 69 | beforeAll(() => { 70 | fixture = slash(path.join(__dirname, './fixtures/simple/invalid.js')) 71 | }) 72 | 73 | it('should have one result', () => { 74 | expect(data.results.length).toEqual(1) 75 | }) 76 | 77 | it('should use the right file', () => { 78 | expect(slash(data.results[0].source)).toEqual(fixture) 79 | }) 80 | 81 | it('should have errored', () => { 82 | expect(data.results[0].errored).toEqual(true) 83 | }) 84 | 85 | it('should have two warnings (i.e. wrong lines of code)', () => { 86 | expect(data.results[0].warnings.length).toEqual(2) 87 | }) 88 | 89 | it('should have a block-no-empty as the first warning', () => { 90 | expect(data.results[0].warnings[0].rule).toEqual('block-no-empty') 91 | }) 92 | 93 | it('should have an indentation as the first warning', () => { 94 | expect(data.results[0].warnings[1].rule).toEqual('indentation') 95 | }) 96 | }) 97 | 98 | describe('helpers', () => { 99 | beforeAll(() => { 100 | fixture = slash(path.join(__dirname, './fixtures/simple/helpers.js')) 101 | }) 102 | 103 | it('should have one result', () => { 104 | expect(data.results.length).toEqual(1) 105 | }) 106 | 107 | it('should use the right file', () => { 108 | expect(slash(data.results[0].source)).toEqual(fixture) 109 | }) 110 | 111 | it('should have errored', () => { 112 | expect(data.results[0].errored).toEqual(true) 113 | }) 114 | 115 | it('should have 11 warnings (i.e. wrong lines of code)', () => { 116 | expect(data.results[0].warnings.length).toEqual(11) 117 | }) 118 | 119 | it('should be indentation and "empty line before" warnings', () => { 120 | const warnings = data.results[0].warnings.reduce( 121 | (all, { rule }) => (all.indexOf(rule) >= 0 ? all : all.concat(rule)), 122 | [] 123 | ) 124 | 125 | expect(warnings).toEqual(['rule-empty-line-before', 'indentation']) 126 | }) 127 | }) 128 | 129 | describe('import names', () => { 130 | beforeAll(() => { 131 | fixture = slash(path.join(__dirname, './fixtures/simple/imports.js')) 132 | }) 133 | 134 | it('should have one result', () => { 135 | expect(data.results.length).toEqual(1) 136 | }) 137 | 138 | it('should use the right file', () => { 139 | expect(slash(data.results[0].source)).toEqual(fixture) 140 | }) 141 | 142 | it('should have errored, even with a different name', () => { 143 | expect(data.results[0].errored).toEqual(true) 144 | }) 145 | 146 | it('should have 9 warnings, even with a different name (i.e. wrong lines of code)', () => { 147 | expect(data.results[0].warnings.length).toEqual(9) 148 | }) 149 | 150 | it('should all be indentation warnings, even with a different name', () => { 151 | data.results[0].warnings.forEach(warning => { 152 | expect(warning.rule).toEqual('indentation') 153 | }) 154 | }) 155 | }) 156 | 157 | describe('nesting', () => { 158 | beforeAll(() => { 159 | fixture = slash(path.join(__dirname, './fixtures/simple/nesting.js')) 160 | }) 161 | 162 | it('should have one result', () => { 163 | expect(data.results.length).toEqual(1) 164 | }) 165 | 166 | it('should use the right file', () => { 167 | expect(slash(data.results[0].source)).toEqual(fixture) 168 | }) 169 | 170 | it('should not have errored', () => { 171 | expect(data.errored).toEqual(false) 172 | }) 173 | 174 | it('should not have any warnings', () => { 175 | expect(data.results[0].warnings.length).toEqual(0) 176 | }) 177 | }) 178 | 179 | describe('global variables', () => { 180 | beforeAll(() => { 181 | fixture = slash(path.join(__dirname, './fixtures/simple/global.js')) 182 | }) 183 | 184 | it('should have one result', () => { 185 | expect(data.results.length).toEqual(1) 186 | }) 187 | 188 | it('should use the right file', () => { 189 | expect(slash(data.results[0].source)).toEqual(fixture) 190 | }) 191 | 192 | it('should have errored', () => { 193 | expect(data.errored).toEqual(true) 194 | }) 195 | 196 | it('should have 5 warnings', () => { 197 | expect(data.results[0].warnings.length).toEqual(5) 198 | }) 199 | }) 200 | 201 | describe('other library', () => { 202 | beforeAll(() => { 203 | fixture = slash(path.join(__dirname, './fixtures/simple/other-library.js')) 204 | }) 205 | 206 | it('should have one result', () => { 207 | expect(data.results.length).toEqual(1) 208 | }) 209 | 210 | it('should use the right file', () => { 211 | expect(slash(data.results[0].source)).toEqual(fixture) 212 | }) 213 | 214 | it('should have errored', () => { 215 | expect(data.errored).toEqual(true) 216 | }) 217 | 218 | it('should have 1 warning', () => { 219 | expect(data.results[0].warnings.length).toEqual(1) 220 | }) 221 | }) 222 | 223 | describe('identify styled', () => { 224 | beforeAll(() => { 225 | fixture = slash(path.join(__dirname, './fixtures/simple/identify-styled.js')) 226 | }) 227 | 228 | it('should have one result', () => { 229 | expect(data.results.length).toEqual(1) 230 | }) 231 | 232 | it('should use the right file', () => { 233 | expect(slash(data.results[0].source)).toEqual(fixture) 234 | }) 235 | 236 | it('should have errored', () => { 237 | expect(data.errored).toEqual(true) 238 | }) 239 | 240 | it('should have 5 warnings', () => { 241 | expect(data.results[0].warnings.length).toEqual(5) 242 | }) 243 | 244 | it('should have correct warnings', () => { 245 | const { warnings } = data.results[0] 246 | expect(warnings[0].line).toBe(4) 247 | expect(warnings[0].rule).toBe('selector-type-no-unknown') 248 | 249 | expect(warnings[1].line).toBe(10) 250 | expect(warnings[1].rule).toBe('selector-type-no-unknown') 251 | 252 | expect(warnings[2].line).toBe(16) 253 | expect(warnings[2].rule).toBe('selector-type-no-unknown') 254 | 255 | expect(warnings[3].line).toBe(22) 256 | expect(warnings[3].rule).toBe('selector-type-no-unknown') 257 | 258 | expect(warnings[4].line).toBe(28) 259 | expect(warnings[4].rule).toBe('selector-type-no-unknown') 260 | }) 261 | }) 262 | 263 | describe('parse successfully', () => { 264 | beforeAll(() => { 265 | fixture = slash(path.join(__dirname, './fixtures/simple/parse.js')) 266 | }) 267 | 268 | it('should have one result', () => { 269 | expect(data.results.length).toEqual(1) 270 | }) 271 | 272 | it('should use the right file', () => { 273 | expect(slash(data.results[0].source)).toEqual(fixture) 274 | }) 275 | 276 | it('should not have errored', () => { 277 | expect(data.errored).toEqual(false) 278 | }) 279 | 280 | it('should not have any warnings', () => { 281 | expect(data.results[0].warnings.length).toEqual(0) 282 | }) 283 | }) 284 | }) 285 | -------------------------------------------------------------------------------- /test/options.test.js: -------------------------------------------------------------------------------- 1 | const stylelint = require('stylelint') 2 | const path = require('path') 3 | const slash = require('slash') 4 | 5 | const processor = path.join(__dirname, '../src/index.js') 6 | const rules = { 7 | 'block-no-empty': true, 8 | indentation: 2, 9 | 'rule-empty-line-before': [ 10 | 'always-multi-line', 11 | { 12 | except: ['first-nested'], 13 | ignore: ['after-comment'] 14 | } 15 | ], 16 | 'selector-type-no-unknown': true 17 | } 18 | 19 | describe('options', () => { 20 | let fixture 21 | let data 22 | 23 | describe('moduleName', () => { 24 | // NOTE beforeEach() runs _after_ the beforeAll() hooks of the describe() blocks, so `fixture` 25 | // will have the right path 26 | beforeEach(done => { 27 | stylelint 28 | .lint({ 29 | files: [fixture], 30 | config: { 31 | // Set moduleName option to "emotion" 32 | processors: [[processor, { moduleName: 'emotion' }]], 33 | rules 34 | } 35 | }) 36 | .then(result => { 37 | data = result 38 | done() 39 | }) 40 | .catch(err => { 41 | // eslint-disable-next-line no-console 42 | console.log(err) 43 | data = err 44 | done() 45 | }) 46 | }) 47 | 48 | describe('moduleName', () => { 49 | beforeAll(() => { 50 | fixture = slash(path.join(__dirname, './fixtures/options/module-name.js')) 51 | }) 52 | 53 | it('should have one result', () => { 54 | expect(data.results.length).toEqual(1) 55 | }) 56 | 57 | it('should use the right file', () => { 58 | expect(slash(data.results[0].source)).toEqual(fixture) 59 | }) 60 | 61 | it('should have errored', () => { 62 | expect(data.results[0].errored).toEqual(true) 63 | }) 64 | 65 | it('should have one warning (i.e. wrong lines of code)', () => { 66 | expect(data.results[0].warnings.length).toEqual(1) 67 | }) 68 | 69 | it('should have a block-no-empty as the first warning', () => { 70 | expect(data.results[0].warnings[0].rule).toEqual('block-no-empty') 71 | }) 72 | }) 73 | 74 | describe('relative moduleName', () => { 75 | beforeAll(() => { 76 | fixture = slash(path.join(__dirname, './fixtures/options/relative-module-name.js')) 77 | }) 78 | 79 | it('should have one result', () => { 80 | expect(data.results.length).toEqual(1) 81 | }) 82 | 83 | it('should use the right file', () => { 84 | expect(slash(data.results[0].source)).toEqual(fixture) 85 | }) 86 | 87 | it('should have errored', () => { 88 | expect(data.results[0].errored).toEqual(true) 89 | }) 90 | 91 | it('should have one warning (i.e. wrong lines of code)', () => { 92 | expect(data.results[0].warnings.length).toEqual(1) 93 | }) 94 | 95 | it('should have a block-no-empty as the first warning', () => { 96 | expect(data.results[0].warnings[0].rule).toEqual('block-no-empty') 97 | }) 98 | }) 99 | 100 | describe('invalid moduleName', () => { 101 | beforeAll(() => { 102 | fixture = slash(path.join(__dirname, './fixtures/options/invalid-module-name.js')) 103 | }) 104 | 105 | it('should have one result', () => { 106 | expect(data.results.length).toEqual(1) 107 | }) 108 | 109 | it('should use the right file', () => { 110 | expect(slash(data.results[0].source)).toEqual(fixture) 111 | }) 112 | 113 | it('should not have errored', () => { 114 | expect(data.results[0].errored).toEqual(false) 115 | }) 116 | }) 117 | }) 118 | 119 | describe('importName', () => { 120 | // NOTE beforeEach() runs _after_ the beforeAll() hooks of the describe() blocks, so `fixture` 121 | // will have the right path 122 | beforeEach(done => { 123 | stylelint 124 | .lint({ 125 | files: [fixture], 126 | config: { 127 | // Set importName option to "notDefault" 128 | processors: [[processor, { importName: 'notDefault' }]], 129 | rules 130 | } 131 | }) 132 | .then(result => { 133 | data = result 134 | done() 135 | }) 136 | .catch(err => { 137 | // eslint-disable-next-line no-console 138 | console.log(err) 139 | data = err 140 | done() 141 | }) 142 | }) 143 | 144 | describe('importName', () => { 145 | beforeAll(() => { 146 | fixture = slash(path.join(__dirname, './fixtures/options/import-name.js')) 147 | }) 148 | 149 | it('should have one result', () => { 150 | expect(data.results.length).toEqual(1) 151 | }) 152 | 153 | it('should use the right file', () => { 154 | expect(slash(data.results[0].source)).toEqual(fixture) 155 | }) 156 | 157 | it('should have errored', () => { 158 | expect(data.results[0].errored).toEqual(true) 159 | }) 160 | 161 | it('should have one warning (i.e. wrong lines of code)', () => { 162 | expect(data.results[0].warnings.length).toEqual(1) 163 | }) 164 | 165 | it('should have a block-no-empty as the first warning', () => { 166 | expect(data.results[0].warnings[0].rule).toEqual('block-no-empty') 167 | }) 168 | }) 169 | 170 | describe('invalid importName', () => { 171 | beforeAll(() => { 172 | fixture = slash(path.join(__dirname, './fixtures/options/invalid-import-name.js')) 173 | }) 174 | 175 | it('should have one result', () => { 176 | expect(data.results.length).toEqual(1) 177 | }) 178 | 179 | it('should use the right file', () => { 180 | expect(slash(data.results[0].source)).toEqual(fixture) 181 | }) 182 | 183 | it('should not have errored', () => { 184 | expect(data.results[0].errored).toEqual(false) 185 | }) 186 | }) 187 | }) 188 | 189 | describe('parserPlugins', () => { 190 | // NOTE beforeEach() runs _after_ the beforeAll() hooks of the describe() blocks, so `fixture` 191 | // will have the right path 192 | beforeEach(done => { 193 | const plugins = [ 194 | 'jsx', 195 | ['decorators', { decoratorsBeforeExport: true }], 196 | 'classProperties', 197 | 'exportExtensions', 198 | 'functionBind', 199 | 'functionSent', 200 | // Enable experimental feature 201 | 'exportDefaultFrom' 202 | ] 203 | 204 | stylelint 205 | .lint({ 206 | code: "export Container from './Container';", 207 | config: { 208 | processors: [[processor, { parserPlugins: plugins }]], 209 | rules 210 | } 211 | }) 212 | .then(result => { 213 | data = result 214 | done() 215 | }) 216 | .catch(err => { 217 | // eslint-disable-next-line no-console 218 | console.log(err) 219 | data = err 220 | done() 221 | }) 222 | }) 223 | 224 | it('should have one result', () => { 225 | expect(data.results.length).toEqual(1) 226 | }) 227 | 228 | it('should have not errored', () => { 229 | expect(data.results[0].errored).toEqual(false) 230 | }) 231 | }) 232 | 233 | describe('strict', () => { 234 | beforeEach(done => { 235 | fixture = slash(path.join(__dirname, './fixtures/options/strict.js')) 236 | 237 | stylelint 238 | .lint({ 239 | files: [fixture], 240 | config: { 241 | processors: [ 242 | [ 243 | processor, 244 | { 245 | moduleName: 'some-module', 246 | importName: 'foo', 247 | strict: true 248 | } 249 | ] 250 | ], 251 | rules 252 | } 253 | }) 254 | .then(result => { 255 | data = result 256 | done() 257 | }) 258 | .catch(err => { 259 | // eslint-disable-next-line no-console 260 | console.log(err) 261 | data = err 262 | done() 263 | }) 264 | }) 265 | 266 | it('should have one result', () => { 267 | expect(data.results.length).toEqual(1) 268 | }) 269 | 270 | it('should use the right file', () => { 271 | expect(slash(data.results[0].source)).toEqual(fixture) 272 | }) 273 | 274 | it('should have errored', () => { 275 | expect(data.results[0].errored).toEqual(true) 276 | }) 277 | 278 | it('should have one warning (i.e. wrong lines of code)', () => { 279 | expect(data.results[0].warnings.length).toEqual(1) 280 | }) 281 | 282 | it('should have a block-no-empty as the first warning', () => { 283 | expect(data.results[0].warnings[0].rule).toEqual('block-no-empty') 284 | }) 285 | }) 286 | }) 287 | -------------------------------------------------------------------------------- /test/utils.test.js: -------------------------------------------------------------------------------- 1 | const { 2 | interleave, 3 | hasInterpolationTag, 4 | parseInterpolationTag, 5 | extractScTagInformation 6 | } = require('../src/utils/tagged-template-literal') 7 | const { 8 | isLastDeclarationCompleted, 9 | nextNonWhitespaceChar, 10 | reverseString, 11 | isStylelintComment, 12 | fixIndentation, 13 | extrapolateShortenedCommand, 14 | removeBaseIndentation 15 | } = require('../src/utils/general') 16 | const { isCausedBySubstitution, getCorrectColumn } = require('../src/utils/result') 17 | 18 | const mockLoc = (startLine, endLine) => ({ 19 | start: { 20 | line: startLine 21 | }, 22 | end: { 23 | line: endLine 24 | } 25 | }) 26 | 27 | describe('utils', () => { 28 | describe('interleave', () => { 29 | it('should return the value of the node if no interpolation exists', () => { 30 | const raw = 'color: blue;' 31 | const quasis = [ 32 | { 33 | value: { 34 | raw 35 | } 36 | } 37 | ] 38 | expect(interleave(quasis, [])).toEqual(raw) 39 | }) 40 | 41 | it('should variabelize an interpolation', () => { 42 | const quasis = [ 43 | { 44 | loc: mockLoc(1, 3), 45 | value: { 46 | raw: '\n display: block;\n color: ' 47 | } 48 | }, 49 | { 50 | loc: mockLoc(3, 5), 51 | value: { 52 | raw: ';\n background: blue;\n' 53 | } 54 | } 55 | ] 56 | const expressions = [ 57 | { 58 | loc: mockLoc(3, 3), 59 | name: 'color' 60 | } 61 | ] 62 | expect(interleave(quasis, expressions)).toEqual( 63 | '\n display: block;\n color: $dummyValue;\n background: blue;\n' 64 | ) 65 | }) 66 | 67 | it('converts interpolated expressions to dummy mixins', () => { 68 | const quasis = [ 69 | { 70 | loc: mockLoc(1, 3), 71 | value: { 72 | raw: '\n display: block;\n ' 73 | } 74 | }, 75 | { 76 | loc: mockLoc(3, 5), 77 | value: { 78 | raw: '\n background: blue;\n' 79 | } 80 | } 81 | ] 82 | const expressions = [ 83 | { 84 | loc: mockLoc(3, 3), 85 | name: undefined 86 | } 87 | ] 88 | expect(interleave(quasis, expressions)).toEqual( 89 | '\n display: block;\n -styled-mixin0: dummyValue;\n background: blue;\n' 90 | ) 91 | }) 92 | 93 | it('correctly converts several interpolations within a single property', () => { 94 | const quasis = [ 95 | { 96 | loc: mockLoc(1, 3), 97 | value: { 98 | raw: '\n display: block;\n border: ' 99 | } 100 | }, 101 | { 102 | loc: mockLoc(3, 3), 103 | value: { 104 | raw: ' ' 105 | } 106 | }, 107 | { 108 | loc: mockLoc(3, 3), 109 | value: { 110 | raw: ' ' 111 | } 112 | }, 113 | { 114 | loc: mockLoc(3, 5), 115 | value: { 116 | raw: ';\n background: blue;\n' 117 | } 118 | } 119 | ] 120 | const expressions = [ 121 | { 122 | loc: mockLoc(3, 3), 123 | name: 'borderWidth' 124 | }, 125 | { 126 | loc: mockLoc(3, 3), 127 | name: 'borderStyle' 128 | }, 129 | { 130 | loc: mockLoc(3, 3), 131 | name: 'color' 132 | } 133 | ] 134 | expect(interleave(quasis, expressions)).toEqual( 135 | '\n display: block;\n border: $dummyValue $dummyValue $dummyValue;\n background: blue;\n' 136 | ) 137 | }) 138 | 139 | it('correctly handles several interpolations in single line of css', () => { 140 | const quasis1 = [ 141 | { 142 | loc: mockLoc(1, 2), 143 | value: { 144 | raw: '\n display: ' 145 | } 146 | }, 147 | { 148 | loc: mockLoc(2, 2), 149 | value: { 150 | raw: '; background: ' 151 | } 152 | }, 153 | { 154 | loc: mockLoc(2, 3), 155 | value: { 156 | raw: ';\n' 157 | } 158 | } 159 | ] 160 | const expressions1 = [ 161 | { 162 | loc: mockLoc(2, 2), 163 | name: 'display' 164 | }, 165 | { 166 | loc: mockLoc(2, 2), 167 | name: 'background' 168 | } 169 | ] 170 | expect(interleave(quasis1, expressions1)).toEqual( 171 | '\n display: $dummyValue; background: $dummyValue;\n' 172 | ) 173 | 174 | const quasis2 = [ 175 | { 176 | loc: mockLoc(1, 2), 177 | value: { 178 | raw: '\n display: ' 179 | } 180 | }, 181 | { 182 | loc: mockLoc(2, 2), 183 | value: { 184 | raw: '; ' 185 | } 186 | }, 187 | { 188 | loc: mockLoc(2, 3), 189 | value: { 190 | raw: '\n' 191 | } 192 | } 193 | ] 194 | const expressions2 = [ 195 | { 196 | loc: mockLoc(2, 2), 197 | name: 'display' 198 | }, 199 | { 200 | loc: mockLoc(3, 3), 201 | name: undefined 202 | } 203 | ] 204 | expect(interleave(quasis2, expressions2)).toEqual( 205 | '\n display: $dummyValue; -styled-mixin0: dummyValue;\n' 206 | ) 207 | 208 | /** 209 | * It is important to also have this one as interleave would fail this if it simply 210 | * checked the previous quasi and not the previous processed text. 211 | * Here we also check the whole expression with and without a semi-colon in the quasi 212 | */ 213 | const quasis3 = [ 214 | { 215 | loc: mockLoc(1, 2), 216 | value: { 217 | raw: '\n display: ' 218 | } 219 | }, 220 | { 221 | loc: mockLoc(2, 2), 222 | value: { 223 | raw: '; ' 224 | } 225 | }, 226 | { 227 | loc: mockLoc(2, 2), 228 | value: { 229 | raw: ' ' 230 | } 231 | }, 232 | { 233 | loc: mockLoc(2, 3), 234 | value: { 235 | raw: '\n' 236 | } 237 | } 238 | ] 239 | const expressions3 = [ 240 | { 241 | loc: mockLoc(2, 2), 242 | name: 'display' 243 | }, 244 | { 245 | loc: mockLoc(2, 2), 246 | name: undefined 247 | }, 248 | { 249 | loc: mockLoc(2, 2), 250 | name: undefined 251 | } 252 | ] 253 | expect(interleave(quasis3, expressions3)).toEqual( 254 | '\n display: $dummyValue; -styled-mixin0: dummyValue; -styled-mixin1: dummyValue;\n' 255 | ) 256 | }) 257 | }) 258 | 259 | describe('reverseString', () => { 260 | const fn = reverseString 261 | 262 | it('reverses a string', () => { 263 | expect(fn('abcd')).toEqual('dcba') 264 | }) 265 | 266 | it('handles empty string', () => { 267 | expect(fn('')).toEqual('') 268 | }) 269 | }) 270 | 271 | describe('nextNonWhitespaceChar', () => { 272 | const fn = nextNonWhitespaceChar 273 | 274 | it('handles empty string', () => { 275 | expect(fn('')).toBe(null) 276 | }) 277 | 278 | it('handles all whitespace', () => { 279 | expect(fn(' \t \n \t')).toBe(null) 280 | }) 281 | 282 | it('handles no leading whitespace', () => { 283 | expect(fn('abc')).toBe('a') 284 | }) 285 | 286 | it('handles spaces', () => { 287 | expect(fn(' b')).toBe('b') 288 | }) 289 | 290 | it('handles tabs', () => { 291 | expect(fn('\tc')).toBe('c') 292 | }) 293 | 294 | it('handles newlines', () => { 295 | expect(fn('\nd')).toBe('d') 296 | }) 297 | 298 | it('handles a mix', () => { 299 | expect(fn(' \n\t\ra \t\r\nb')).toBe('a') 300 | }) 301 | }) 302 | 303 | describe('isLastDeclarationCompleted', () => { 304 | const fn = isLastDeclarationCompleted 305 | 306 | it('handles all whitespace', () => { 307 | expect(fn(' \n \n \t \r')).toBe(true) 308 | }) 309 | 310 | it('handles empty string', () => { 311 | expect(fn('')).toBe(true) 312 | }) 313 | 314 | it('handles one-line css', () => { 315 | const prevCSS = 'display: block; color: red ' 316 | expect(fn(prevCSS)).toBe(false) 317 | 318 | expect(fn(`${prevCSS};`)).toBe(true) 319 | }) 320 | 321 | it('handles multi-line css', () => { 322 | const prevCSS = ` 323 | display: block; 324 | color: red` 325 | expect(fn(prevCSS)).toBe(false) 326 | 327 | expect(fn(`${prevCSS};\n`)).toBe(true) 328 | }) 329 | 330 | it('handles irregular css', () => { 331 | const prevCSS = `display : block 332 | ; color: 333 | red ` 334 | expect(fn(prevCSS)).toBe(false) 335 | 336 | expect( 337 | fn(`${prevCSS} 338 | 339 | ; 340 | 341 | `) 342 | ).toBe(true) 343 | }) 344 | 345 | it('handles declaration blocks', () => { 346 | const prevCSS = ` 347 | @media screen and (max-width: 600px) { 348 | display: block; 349 | color: red; 350 | } 351 | ` 352 | expect(fn(prevCSS)).toBe(true) 353 | }) 354 | 355 | it('handles being inside a declaration block', () => { 356 | const prevCSS = ` 357 | span { 358 | ` 359 | expect(fn(prevCSS)).toBe(true) 360 | }) 361 | 362 | it('handles being preceded by a comment', () => { 363 | const prevCSS1 = ` 364 | display: block; /* stylelint-disable */ 365 | ` 366 | expect(fn(prevCSS1)).toBe(true) 367 | 368 | const prevCSS2 = ` 369 | display: block; 370 | /* stylelint-disable */ 371 | ` 372 | expect(fn(prevCSS2)).toBe(true) 373 | 374 | const prevCSS3 = ` 375 | display: block; 376 | /* 377 | multiline comment with "*" and "/" 378 | */ 379 | ` 380 | expect(fn(prevCSS3)).toBe(true) 381 | 382 | const prevCSS4 = ` 383 | display: block; 384 | /* 385 | * JSDoc style comment 386 | */ 387 | ` 388 | expect(fn(prevCSS4)).toBe(true) 389 | 390 | const prevCSS5 = ` 391 | display: /* stylelint-disable */ 392 | ` 393 | expect(fn(prevCSS5)).toBe(false) 394 | 395 | const prevCSS6 = ` 396 | display: 397 | /* stylelint-disable */ 398 | ` 399 | expect(fn(prevCSS6)).toBe(false) 400 | 401 | const prevCSS7 = ` 402 | display: 403 | /* 404 | multiline comment with "*" and "/" 405 | */ 406 | ` 407 | expect(fn(prevCSS7)).toBe(false) 408 | 409 | const prevCSS8 = ` 410 | display: 411 | /* 412 | * JSDoc style comment 413 | */ 414 | ` 415 | expect(fn(prevCSS8)).toBe(false) 416 | }) 417 | }) 418 | 419 | describe('isStylelintComment', () => { 420 | const fn = isStylelintComment 421 | 422 | it('should match general block ignores', () => { 423 | expect(fn('stylelint-disable')).toBe(true) 424 | 425 | expect(fn('stylelint-enable')).toBe(true) 426 | }) 427 | 428 | it('should match block ignores with any arguments', () => { 429 | expect(fn('stylelint-enable some-rule')).toBe(true) 430 | 431 | expect(fn('stylelint-disable asdfsafdsa-fdsafd9a0fd9sa0f asfd8af afdsa7f')).toBe(true) 432 | }) 433 | 434 | it("shouldn't match line specific ignores", () => { 435 | expect(fn('stylelint-disable-line')).toBe(false) 436 | 437 | expect(fn('stylelint-disable-next-line')).toBe(false) 438 | }) 439 | 440 | it('should handle whitespace in start and end', () => { 441 | expect(fn(' \tstylelint-disable \t')).toBe(true) 442 | }) 443 | }) 444 | 445 | describe('fixIndentation', () => { 446 | // We only check the one-line case for now as the rest is be covered thoroughly in hard.test.js 447 | it('leaves one-line css alone', () => { 448 | const test1 = 'display: block;' 449 | expect(fixIndentation(test1).text).toBe(test1) 450 | 451 | const test2 = ' display: block;' 452 | expect(fixIndentation(test2).text).toBe(test2) 453 | 454 | const test3 = '\t\tdisplay:block;' 455 | expect(fixIndentation(test3).text).toBe(test3) 456 | }) 457 | }) 458 | 459 | describe('hasInterpolationTag', () => { 460 | const fn = hasInterpolationTag 461 | it('works for starting comment', () => { 462 | const expression = { 463 | leadingComments: [{ value: ' sc-block ' }], 464 | trailingComments: [] 465 | } 466 | expect(fn(expression)).toBe(true) 467 | }) 468 | 469 | it('correctly identifies lack of tag', () => { 470 | const expression = { 471 | leadingComments: [{ value: 'some test value' }], 472 | trailingComments: [{ value: 'second test value' }] 473 | } 474 | expect(fn(expression)).toBe(false) 475 | }) 476 | 477 | it('handles tag not being first comment', () => { 478 | const expression = { 479 | leadingComments: [{ value: 'some test value' }, { value: 'second test value' }], 480 | trailingComments: [{ value: ' sc-s' }] 481 | } 482 | expect(fn(expression)).toBe(true) 483 | }) 484 | }) 485 | 486 | describe('parseInterpolationTag', () => { 487 | const fn = parseInterpolationTag 488 | const prepExpression = command => ({ 489 | leadingComments: [ 490 | { value: 'some test comment' }, 491 | { 492 | value: `sc-${command}`, 493 | loc: { 494 | start: { 495 | line: 1, 496 | column: 3 497 | } 498 | } 499 | } 500 | ], 501 | trailingComments: [] 502 | }) 503 | it('handles the API', () => { 504 | const selectorExpression = prepExpression('selector') 505 | expect(fn(selectorExpression, 1, 'path/to/file')).toBe('.sc-selector1') 506 | 507 | const blockExpression = prepExpression('block') 508 | expect(fn(blockExpression, 1, 'path/to/file')).toBe('-styled-mixin1: dummyValue;') 509 | 510 | const declarationExpression = prepExpression('declaration') 511 | expect(fn(declarationExpression, 1, 'path/to/file')).toBe('-styled-mixin1: dummyValue;') 512 | 513 | const propertyExpression = prepExpression('property') 514 | expect(fn(propertyExpression, 1, 'path/to/file')).toBe('-styled-mixin1') 515 | 516 | const valueExpression = prepExpression('value') 517 | expect(fn(valueExpression, 1, 'path/to/file')).toBe('$dummyValue') 518 | 519 | const customExpression = prepExpression('custom " my test placeholder"') 520 | expect(fn(customExpression, 1, 'path/to/file')).toBe(' my test placeholder') 521 | }) 522 | 523 | it('throws on invalid tag', () => { 524 | const invalidExpression = prepExpression('invalid') 525 | expect(fn.bind(null, invalidExpression, 1, 'path/to/file')).toThrow( 526 | /path\/to\/file:1:3:.*invalid sc- tag/ 527 | ) 528 | }) 529 | }) 530 | 531 | describe('extrapolateShortenedCommand', () => { 532 | const fn = extrapolateShortenedCommand 533 | const commands = ['hello', 'heaven', 'command'] 534 | 535 | it('handles correctly shortened commands', () => { 536 | expect(fn(commands, 'hel')).toBe('hello') 537 | expect(fn(commands, 'hea')).toBe('heaven') 538 | expect(fn(commands, 'c')).toBe('command') 539 | expect(fn(commands, 'comm')).toBe('command') 540 | }) 541 | 542 | it('handles full commands', () => { 543 | expect(fn(commands, 'hello')).toBe('hello') 544 | expect(fn(commands, 'heaven')).toBe('heaven') 545 | expect(fn(commands, 'command')).toBe('command') 546 | }) 547 | 548 | it('rejects ambigously shortened commands', () => { 549 | expect(fn.bind(this, commands, 'h')).toThrow() 550 | expect(fn.bind(this, commands, 'he', '/path/to/file', { line: 4, column: 6 })).toThrow( 551 | /path\/to\/file:4:6:/ 552 | ) 553 | }) 554 | 555 | it('rejects nonsense', () => { 556 | expect(fn(commands, 'nonsense')).toBeNull() 557 | expect(fn(commands, 'asdfasfd')).toBeNull() 558 | expect(fn(commands, 'x')).toBeNull() 559 | }) 560 | }) 561 | 562 | describe('extractScTagInformation', () => { 563 | const fn = extractScTagInformation 564 | it('handles normal Sc Tag', () => { 565 | expect(fn(' sc-block ')).toEqual({ 566 | command: 'block', 567 | customPlaceholder: undefined 568 | }) 569 | 570 | expect(fn('sc-selector')).toEqual({ 571 | command: 'selector', 572 | customPlaceholder: undefined 573 | }) 574 | 575 | expect(fn('sc-block ')).toEqual({ 576 | command: 'block', 577 | customPlaceholder: undefined 578 | }) 579 | 580 | expect(fn('sc-block ')).toEqual({ 581 | command: 'block', 582 | customPlaceholder: undefined 583 | }) 584 | }) 585 | 586 | it('handles custom placeholder', () => { 587 | expect(fn(' sc-custom "placeholder test" ')).toEqual({ 588 | command: 'custom', 589 | customPlaceholder: 'placeholder test' 590 | }) 591 | 592 | expect(fn(" sc-custom 'placeholder test' ")).toEqual({ 593 | command: 'custom', 594 | customPlaceholder: 'placeholder test' 595 | }) 596 | }) 597 | }) 598 | 599 | describe('removeBaseIndentation', () => { 600 | const fn = removeBaseIndentation 601 | it('removes indentation correctly', () => { 602 | const inputCss = ` 603 | html { 604 | color: red; 605 | } 606 | ` 607 | const expectedOutput = ` 608 | html { 609 | color: red; 610 | } 611 | ` 612 | expect(fn(inputCss)).toBe(expectedOutput) 613 | }) 614 | 615 | it('handles oneline css', () => { 616 | const inputCss = ' display: block; ' 617 | const expectedOutput = 'display: block;' 618 | expect(fn(inputCss)).toBe(expectedOutput) 619 | }) 620 | 621 | it('handles no indentation edge case', () => { 622 | const inputCss = '\ndisplay: block;\ncolor: red;' 623 | const expectedOutput = inputCss 624 | expect(fn(inputCss)).toBe(expectedOutput) 625 | }) 626 | }) 627 | 628 | describe('isCausedBySubstitution', () => { 629 | const fn = isCausedBySubstitution 630 | const interpolationLines = [ 631 | { start: 2, end: 4 }, 632 | { start: 5, end: 5 } 633 | ] 634 | it("returns true if real warning line is between some interpolation's start and end", () => { 635 | expect(fn({ rule: 'any rule' }, 3, interpolationLines)).toEqual(true) 636 | }) 637 | it("returns false if real warning line is beyond any interpolation's start and end", () => { 638 | expect(fn({ rule: 'any rule' }, 1, interpolationLines)).toEqual(false) 639 | }) 640 | it("checks warning rule if real warning line is at some interpolations' start", () => { 641 | expect(fn({ rule: 'comment-empty-line-before' }, 2, interpolationLines)).toEqual(true) 642 | expect(fn({ rule: 'another rule' }, 2, interpolationLines)).toEqual(false) 643 | }) 644 | it("checks warning rule if real warning line is at some interpolations' end", () => { 645 | expect(fn({ rule: 'comment-empty-line-before' }, 4, interpolationLines)).toEqual(true) 646 | expect(fn({ rule: 'another rule' }, 4, interpolationLines)).toEqual(false) 647 | }) 648 | }) 649 | 650 | describe('getCorrectColumn', () => { 651 | const fn = getCorrectColumn 652 | const taggedTemplateLocs = [ 653 | { wrappedOffset: 3, start: { line: 1, column: 6 } }, 654 | { wrappedOffset: 12, start: { line: 5, column: 39 } } 655 | ] 656 | it('returns identically if the line is not any first line of tagged templates', () => { 657 | expect(fn(taggedTemplateLocs, 4, 1)).toEqual(1) 658 | }) 659 | it('returns correctly if the line is some first line of tagged templates', () => { 660 | expect(fn(taggedTemplateLocs, 5, 23)).toEqual(51) 661 | }) 662 | }) 663 | }) 664 | --------------------------------------------------------------------------------