Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add @blueprintjs/stylelint-plugin package and no-prefix-literal rule #4683

Merged
merged 13 commits into from
Apr 29, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .stylelintrc
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
"stylelint-config-palantir",
"stylelint-config-palantir/sass.js"
],
"plugins": [
"@blueprintjs/stylelint-plugin"
],
"rules": {
"@blueprintjs/no-prefix-literal": [true, { "disableFix": true }],
"declaration-empty-line-before": null,
"indentation": [2, {
"ignore": ["value"]
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ These packages define development dependencies and contain build configuration.
- [![npm](https://img.shields.io/npm/v/@blueprintjs/eslint-plugin.svg?label=@blueprintjs/eslint-plugin)](https://www.npmjs.com/package/@blueprintjs/eslint-plugin) – implementations for custom ESLint rules which enforce best practices for Blueprint usage
- [![npm](https://img.shields.io/npm/v/@blueprintjs/karma-build-scripts.svg?label=@blueprintjs/karma-build-scripts)](https://www.npmjs.com/package/@blueprintjs/karma-build-scripts)
- [![npm](https://img.shields.io/npm/v/@blueprintjs/node-build-scripts.svg?label=@blueprintjs/node-build-scripts)](https://www.npmjs.com/package/@blueprintjs/node-build-scripts) – various utility scripts for linting, working with CSS variables, and building icons
- [![npm](https://img.shields.io/npm/v/@blueprintjs/stylelint-plugin.svg?label=@blueprintjs/stylelint-plugin)](https://www.npmjs.com/package/@blueprintjs/stylelint-plugin) – implementations for custom stylelint rules which enforce best practices for Blueprint usage
- [![npm](https://img.shields.io/npm/v/@blueprintjs/test-commons.svg?label=@blueprintjs/test-commons)](https://www.npmjs.com/package/@blueprintjs/test-commons) – various utility functions used in Blueprint test suites
- [![npm](https://img.shields.io/npm/v/@blueprintjs/tslint-config.svg?label=@blueprintjs/tslint-config)](https://www.npmjs.com/package/@blueprintjs/tslint-config) – TSLint configuration used in this repo and recommended for Blueprint-related projects (should be installed by `@blueprintjs/eslint-config`, not directly)
- [![npm](https://img.shields.io/npm/v/@blueprintjs/webpack-build-scripts.svg?label=@blueprintjs/webpack-build-scripts)](https://www.npmjs.com/package/@blueprintjs/webpack-build-scripts)
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/common/_variables.scss
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
// Namespace appended to the beginning of each CSS class: `.#{$ns}-button`.
// Do not quote this value, for Less consumers.
$ns: bp3 !default;
// Alias for BP users outside this repo
$bp-ns: $ns;

// easily the most important variable, so it comes up top
// (so other variables can use it to define themselves)
Expand Down
10 changes: 10 additions & 0 deletions packages/stylelint-plugin/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"root": true,
"extends": ["../../.eslintrc.js"],
"rules": {
"no-duplicate-imports": "off",
"@typescript-eslint/no-duplicate-imports": ["error"],
"import/no-default-export": "off"
},
"ignorePatterns": ["node_modules", "dist", "lib", "fixtures", "coverage", "__snapshots__", "generated"]
}
69 changes: 69 additions & 0 deletions packages/stylelint-plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<img height="204" src="https://cloud.githubusercontent.com/assets/464822/20228152/d3f36dc2-a804-11e6-80ff-51ada2d13ea7.png">

# [Blueprint](http://blueprintjs.com/) [stylelint](https://stylelint.io/) plugin

Blueprint is a React UI toolkit for the web.

This package contains the [stylelint](https://stylelint.io/) plugin for Blueprint. It provides custom rules which are useful when developing against Blueprint libraries.

**Key features:**

- [Blueprint-specific rules](#Rules) for use with `@blueprintjs` components.

## Installation

```
yarn add --dev @blueprintjs/stylelint-plugin
```

## Usage

Simply add this plugin in your `.stylelintrc` file and then pick the rules that you need. The plugin includes Blueprint-specific rules which enforce semantics particular to usage with `@blueprintjs` packages, but does not turn them on by default.

`.stylelintrc`

```json
{
"plugins": [
"@blueprintjs/stylelint-plugin"
],
"rules": {
"@blueprintjs/no-prefix-literal": true
}
}
```

## Rules

### `@blueprintjs/no-prefix-literal` (autofixable)

Enforce usage of the `bp-ns` constant over namespaced string literals.

The `@blueprintjs` package exports a `bp-ns` CSS variable which contains the prefix for the current version of Blueprint (`bp3` for Blueprint 3, `bp4` for Blueprint 4, and etc). Using the variable instead of hardcoding the prefix means that your code will still work when new major version of Blueprint is released.

```json
{
"rules": {
"@blueprintjs/no-prefix-literal": true
}
}
```

```diff
-.bp3-button > div {
- border: 1px solid black;
-}
+ @import "~@blueprntjs/core/lib/scss/variables";
+
+.#{$bp-ns}-button > div {
+ border: 1px solid black;
+}
```

Optional secondary options:

- `disableFix: boolean` - if true, autofix will be disabled
- `variablesImportPath: { less?: string, sass?: string }` - can be used to configure a custom path for importing Blueprint variables when autofixing.


### [Full Documentation](http://blueprintjs.com/docs) | [Source Code](https://github.com/palantir/blueprint)
33 changes: 33 additions & 0 deletions packages/stylelint-plugin/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "@blueprintjs/stylelint-plugin",
"version": "0.0.0",
"description": "Stylelint rules for use with @blueprintjs packages",
"main": "lib/index.js",
"scripts": {
"compile": "tsc -p src/",
"lint": "run-p lint:es",
"lint:es": "es-lint",
"lint-fix": "es-lint --fix",
"test": "mocha test/index.js"
},
"dependencies": {
"postcss": "^7.0.35",
"postcss-selector-parser": "^6.0.5"
},
"peerDependencies": {
"stylelint": "^13.0.0"
},
"devDependencies": {
"@blueprintjs/node-build-scripts": "^1.5.0",
"@types/stylelint": "^9.10.1",
"mocha": "^8.2.1",
"typescript": "^4.1.2"
},
"repository": {
"type": "git",
"url": "[email protected]:palantir/blueprint.git",
"directory": "packages/stylelint-plugin"
},
"author": "Palantir Technologies",
"license": "Apache-2.0"
}
18 changes: 18 additions & 0 deletions packages/stylelint-plugin/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
* Copyright 2017 Palantir Technologies, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import noPrefixLiteral from "./rules/no-prefix-literal";

export default [noPrefixLiteral];
152 changes: 152 additions & 0 deletions packages/stylelint-plugin/src/rules/no-prefix-literal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/*
* Copyright 2021 Palantir Technologies, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { Root, Result } from "postcss";
import parser from "postcss-selector-parser";
import stylelint from "stylelint";
import type { Plugin, RuleTesterContext } from "stylelint";

import { checkImportExists } from "../utils/checkImportExists";
import { insertImport } from "../utils/insertImport";

const ruleName = "@blueprintjs/no-prefix-literal";

const messages = stylelint.utils.ruleMessages(ruleName, {
expected: (unfixed: string, fixed: string) => `Use the \`${fixed}\` variable instead of the \`${unfixed}\` literal`,
});

const bannedPrefixes = ["bp", "bp3", "bp4"];

interface Options {
disableFix?: boolean;
variablesImportPath?: Partial<Record<Exclude<CssSyntax, CssSyntax.OTHER>, string>>;
}

export default stylelint.createPlugin(ruleName, ((
enabled: boolean,
options: Options | undefined,
context: RuleTesterContext,
) => (root: Root, result: Result) => {
if (!enabled) {
return;
}

const validOptions = stylelint.utils.validateOptions(
result,
ruleName,
{
actual: enabled,
optional: false,
possible: [true, false],
},
{
actual: options,
optional: true,
possible: {
disableFix: [true, false],
variablesImportPath: (obj: unknown) => {
if (typeof obj !== "object" || obj == null) {
return false;
}
// Check that the keys and their values are correct
const allowedKeys = new Set<string>(Object.values(CssSyntax).filter(v => v !== CssSyntax.OTHER));
return Object.keys(obj).every(key => allowedKeys.has(key) && typeof (obj as any)[key] === "string");
},
},
},
);

if (!validOptions) {
return;
styu marked this conversation as resolved.
Show resolved Hide resolved
}

const disableFix = options?.disableFix ?? false;

const cssSyntax = getCssSyntax(root.source?.input.file || "");
if (cssSyntax === CssSyntax.OTHER) {
return;
}

let hasBpVariablesImport: boolean | undefined; // undefined means not checked yet
styu marked this conversation as resolved.
Show resolved Hide resolved
function assertBpVariablesImportExists(cssSyntaxType: CssSyntax.SASS | CssSyntax.LESS) {
const importPath = options?.variablesImportPath?.[cssSyntaxType] ?? BpVariableImportMap[cssSyntaxType];
const extension = CssExtensionMap[cssSyntaxType];
if (hasBpVariablesImport == null) {
hasBpVariablesImport = checkImportExists(root, [importPath, `${importPath}.${extension}`]);
}
if (!hasBpVariablesImport) {
insertImport(root, context, importPath);
hasBpVariablesImport = true;
}
}

root.walkRules(rule => {
parser(selectors => {
selectors.walkClasses(selector => {
for (const bannedPrefix of bannedPrefixes) {
if (!selector.value.startsWith(`${bannedPrefix}-`)) {
continue;
}
if ((context as any).fix && !disableFix) {
assertBpVariablesImportExists(cssSyntax);
rule.selector = rule.selector.replace(bannedPrefix, BpPrefixVariableMap[cssSyntax]);
} else {
stylelint.utils.report({
// HACKHACK - offset by one because otherwise the error is reported at a wrong position
index: selector.sourceIndex + 1,
message: messages.expected(bannedPrefix, BpPrefixVariableMap[cssSyntax]),
node: rule,
result,
ruleName,
});
}
}
});
}).processSync(rule.selector);
});
}) as Plugin);

enum CssSyntax {
SASS = "sass",
LESS = "less",
OTHER = "other",
}

const CssExtensionMap: Record<Exclude<CssSyntax, CssSyntax.OTHER>, string> = {
[CssSyntax.SASS]: "scss",
[CssSyntax.LESS]: "less",
};

const BpPrefixVariableMap: Record<Exclude<CssSyntax, CssSyntax.OTHER>, string> = {
[CssSyntax.SASS]: "#{$bp-ns}",
[CssSyntax.LESS]: "@{bp-ns}",
};

const BpVariableImportMap: Record<Exclude<CssSyntax, CssSyntax.OTHER>, string> = {
[CssSyntax.SASS]: "~@blueprintjs/core/lib/scss/variables",
[CssSyntax.LESS]: "~@blueprintjs/core/lib/less/variables",
};

/**
* Returns the flavor of the CSS we're dealing with.
*/
function getCssSyntax(fileName: string): CssSyntax {
for (const cssSyntax of Object.keys(CssExtensionMap)) {
if (fileName.endsWith(`.${CssExtensionMap[cssSyntax as Exclude<CssSyntax, CssSyntax.OTHER>]}`)) {
return cssSyntax as Exclude<CssSyntax, CssSyntax.OTHER>;
}
}
return CssSyntax.OTHER;
}
9 changes: 9 additions & 0 deletions packages/stylelint-plugin/src/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../../../config/tsconfig.base",
"compilerOptions": {
"lib": ["es6", "dom"],
"module": "commonjs",
"outDir": "../lib",
"target": "ES2015"
}
}
45 changes: 45 additions & 0 deletions packages/stylelint-plugin/src/utils/checkImportExists.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Copyright 2017 Palantir Technologies, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import type { Root } from "postcss";

/**
* Returns true if the given import exists in the file, otherwise returns false.
* If `importPath` is an array, any of the strings has to match in order fortrue to be returned.
*/
export function checkImportExists(root: Root, importPath: string | string[]): boolean {
let hasBpVarsImport = false;
root.walkAtRules(/^import$/i, atRule => {
styu marked this conversation as resolved.
Show resolved Hide resolved
for (const path of typeof importPath === "string" ? [importPath] : importPath) {
// `atRule.params` includes quotes around the string, so we strip them.
if (stripQuotes(atRule.params) === path) {
hasBpVarsImport = true;
return false; // Stop the iteration
}
}
return true;
});
return hasBpVarsImport;
}

function stripQuotes(str: string): string {
if (
(str.charAt(0) === '"' && str.charAt(str.length - 1) === '"') ||
(str.charAt(0) === "'" && str.charAt(str.length - 1) === "'")
) {
return str.substr(1, str.length - 2);
}
return str;
}
Loading