Skip to content

Commit

Permalink
feat(lint-rules): create style lint rules
Browse files Browse the repository at this point in the history
Create a set of base style lint rules to be used by angular repos
  • Loading branch information
josephperrott committed Feb 17, 2023
1 parent dc08392 commit 378c3df
Show file tree
Hide file tree
Showing 11 changed files with 714 additions and 7 deletions.
2 changes: 2 additions & 0 deletions BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,13 @@ pkg_npm(
"tsconfig.json",
":index.bzl",
"//bazel:static_files",
"//lint-rules/stylelint:static_files",
"//lint-rules/tslint:static_files",
"//shared-scripts:static_files",
],
substitutions = NPM_PACKAGE_SUBSTITUTIONS,
deps = [
"//lint-rules/stylelint:lib",
"//lint-rules/tslint:lib",
],
)
Expand Down
20 changes: 20 additions & 0 deletions lint-rules/stylelint/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
load("//tools:defaults.bzl", "ts_library")

ts_library(
name = "lib",
srcs = glob(["*.ts"]),
# Note: stylelint rules need to be written in CommonJS.
devmode_module = "commonjs",
visibility = ["//:npm"],
deps = [
"@npm//@types/node",
"@npm//stylelint",
"@npm//typescript",
],
)

filegroup(
name = "static_files",
srcs = ["ts-node-loader-rule.js"],
visibility = ["//:npm"],
)
52 changes: 52 additions & 0 deletions lint-rules/stylelint/no-concrete-rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import stylelint, {Rule} from 'stylelint';
import {basename} from 'path';

const {utils, createPlugin} = stylelint;

const ruleName = '@angular/no-concrete-rules';
const messages = utils.ruleMessages(ruleName, {
expectedWithPattern: (pattern) =>
`CSS rules must be placed inside a mixin for files matching '${pattern}'.`,
expectedAllFiles: () => `CSS rules must be placed inside a mixin for all files.`,
});

/**
* Stylelint plugin that will log a warning for all top-level CSS rules.
* Can be used in theme files to ensure that everything is inside a mixin.
*/
const ruleFn: Rule<boolean, string> = (isEnabled, options) => {
return (root, result) => {
if (!isEnabled) {
return;
}

const filePattern = options.filePattern ? new RegExp(options.filePattern) : null;
const fileName = basename(root.source!.input.file!);

if ((filePattern !== null && !filePattern.test(fileName)) || !root.nodes) {
return;
}

// Go through all the nodes and report a warning for every CSS rule or mixin inclusion.
// We use a regular `forEach`, instead of the PostCSS walker utils, because we only care
// about the top-level nodes.
root.nodes.forEach((node) => {
if (node.type === 'rule' || (node.type === 'atrule' && node.name === 'include')) {
utils.report({
result,
ruleName,
node,
message:
filePattern !== null
? messages.expectedWithPattern(filePattern)
: messages.expectedAllFiles(),
});
}
});
};
};

ruleFn.ruleName = ruleName;
ruleFn.messages = messages;

export default createPlugin(ruleName, ruleFn);
40 changes: 40 additions & 0 deletions lint-rules/stylelint/no-import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import stylelint, {Rule} from 'stylelint';
import {basename} from 'path';

const {utils, createPlugin} = stylelint;

const ruleName = '@angular/no-import';
const messages = utils.ruleMessages(ruleName, {
expected: () => '@import is not allowed. Use @use instead.',
});

/** Stylelint plugin that doesn't allow `@import` to be used. */
const ruleFn: Rule<boolean, string> = (isEnabled, options) => {
return (root, result) => {
if (!isEnabled) {
return;
}

const excludePattern = options?.exclude ? new RegExp(options.exclude) : null;

if (excludePattern?.test(basename(root.source!.input.file!))) {
return;
}

root.walkAtRules((rule) => {
if (rule.name === 'import') {
utils.report({
result,
ruleName,
message: messages.expected(),
node: rule,
});
}
});
};
};

ruleFn.ruleName = ruleName;
ruleFn.messages = messages;

export default createPlugin(ruleName, ruleFn);
93 changes: 93 additions & 0 deletions lint-rules/stylelint/no-unused-import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import stylelint, {Rule} from 'stylelint';
import {basename, join} from 'path';

const {utils, createPlugin} = stylelint;

const ruleName = '@angular/no-unused-import';
const messages = utils.ruleMessages(ruleName, {
expected: (namespace) => `Namespace ${namespace} is not being used.`,
invalid: (rule) =>
`Failed to extract namespace from ${rule}. material/no-unused-` +
`imports Stylelint rule likely needs to be updated.`,
});

/** Stylelint plugin that flags unused `@use` statements. */
const ruleFn: Rule<boolean, string> = (isEnabled, _options, context) => {
return (root, result) => {
if (!isEnabled) {
return;
}

const fileContent = root.toString();

root.walkAtRules((rule) => {
if (rule.name === 'use') {
const namespace = extractNamespaceFromUseStatement(rule.params);

// Flag namespaces we didn't manage to parse so that we can fix the parsing logic.
if (!namespace) {
utils.report({
result,
ruleName,
message: messages.invalid(rule.params),
node: rule,
});
} else if (!fileContent.includes(namespace + '.')) {
if (context.fix) {
rule.remove();
} else {
utils.report({
result,
ruleName,
message: messages.expected(namespace),
node: rule,
});
}
}
}
});
};
};

ruleFn.ruleName = ruleName;
ruleFn.messages = messages;

/** Extracts the namespace of an `@use` rule from its parameters. */
function extractNamespaceFromUseStatement(params: string): string | null {
const openQuoteIndex = Math.max(params.indexOf(`"`), params.indexOf(`'`));
const closeQuoteIndex = Math.max(
params.indexOf(`"`, openQuoteIndex + 1),
params.indexOf(`'`, openQuoteIndex + 1),
);

if (closeQuoteIndex > -1) {
const asExpression = 'as ';
const asIndex = params.indexOf(asExpression, closeQuoteIndex);
const withIndex = params.indexOf(' with', asIndex);

// If we found an ` as ` expression, we consider the rest of the text as the namespace.
if (asIndex > -1) {
return withIndex == -1
? params.slice(asIndex + asExpression.length).trim()
: params.slice(asIndex + asExpression.length, withIndex).trim();
}

const importPath = params
.slice(openQuoteIndex + 1, closeQuoteIndex)
// Sass allows for leading underscores to be omitted and it technically supports .scss.
.replace(/^_|(\.import)?\.scss$|\.import$/g, '');

// Built-in Sass imports look like `sass:map`.
if (importPath.startsWith('sass:')) {
return importPath.split('sass:')[1];
}

// Sass ignores `/index` and infers the namespace as the next segment in the path.
const fileName = basename(importPath);
return fileName === 'index' ? basename(join(fileName, '..')) : fileName;
}

return null;
}

export default createPlugin(ruleName, ruleFn);
43 changes: 43 additions & 0 deletions lint-rules/stylelint/selector-no-deep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import stylelint, {Rule} from 'stylelint';

const {utils, createPlugin} = stylelint;

const isStandardSyntaxRule = require('stylelint/lib/utils/isStandardSyntaxRule');
const isStandardSyntaxSelector = require('stylelint/lib/utils/isStandardSyntaxSelector');

const ruleName = '@angular/selector-no-deep';
const messages = utils.ruleMessages(ruleName, {
expected: (selector) => `Usage of the /deep/ in "${selector}" is not allowed`,
});

/**
* Stylelint plugin that prevents uses of /deep/ in selectors.
*/
const ruleFn: Rule<boolean, unknown> = (isEnabled) => {
return (root, result) => {
if (!isEnabled) {
return;
}

root.walkRules((rule) => {
if (
rule.parent?.type === 'rule' &&
isStandardSyntaxRule(rule) &&
isStandardSyntaxSelector(rule.selector) &&
rule.selector.includes('/deep/')
) {
utils.report({
result,
ruleName,
message: messages.expected(rule.selector),
node: rule,
});
}
});
};
};

ruleFn.ruleName = ruleName;
ruleFn.messages = messages;

export default createPlugin(ruleName, ruleFn);
45 changes: 45 additions & 0 deletions lint-rules/stylelint/single-line-comment-only.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import stylelint, {Rule} from 'stylelint';
import {basename} from 'path';

const {utils, createPlugin} = stylelint;

const ruleName = '@angular/single-line-comment-only';
const messages = utils.ruleMessages(ruleName, {
expected: () =>
'Multi-line comments are not allowed (e.g. /* */). ' + 'Use single-line comments instead (//).',
});

/**
* Stylelint plugin that doesn't allow multi-line comments to
* be used, because they'll show up in the user's output.
*/
const ruleFn: Rule<boolean, string> = (isEnabled, options) => {
return (root, result) => {
if (!isEnabled) {
return;
}

const filePattern = options?.filePattern ? new RegExp(options.filePattern) : null;

if (filePattern && !filePattern?.test(basename(root.source!.input.file!))) {
return;
}

root.walkComments((comment) => {
// Allow comments starting with `!` since they're used to tell minifiers to preserve the comment.
if (!comment.raws.inline && !comment.text.startsWith('!')) {
utils.report({
result,
ruleName,
message: messages.expected(),
node: comment,
});
}
});
};
};

ruleFn.ruleName = ruleName;
ruleFn.messages = messages;

export default createPlugin(ruleName, ruleFn);
8 changes: 8 additions & 0 deletions lint-rules/stylelint/ts-node-loader-rule.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const path = require('path');
const stylelint = require('stylelint');

// Custom rule that registers all of the custom rules, written in TypeScript, with ts-node.
require('ts-node').register();

// Dummy rule so Stylelint doesn't complain that there aren't rules in the file.
module.exports = stylelint.createPlugin('@angular/rules-loader', () => {});
3 changes: 3 additions & 0 deletions package.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ basePackageSubstitutions = {
"//bazel:": "@npm//@angular/build-tooling/bazel:",
"//lint-rules/tslint/": "@npm//@angular/build-tooling/tslint/",
"//lint-rules/tslint:": "@npm//@angular/build-tooling/tslint:",
"//lint-rules/stylelint/": "@npm//@angular/build-tooling/stylelint/",
"//lint-rules/stylelint:": "@npm//@angular/build-tooling/stylelint:",
"//shared-scripts/": "@npm//@angular/build-tooling/shared-scripts/",
"//shared-scripts:": "@npm//@angular/build-tooling/shared-scripts:",
"//:tsconfig.json": "@npm//@angular/build-tooling:tsconfig.json",
Expand All @@ -37,4 +39,5 @@ BZL_DEFAULTS_ALLOW_PACKAGES = [
"ng-dev",
"tools",
"lint-rules/tslint",
"lint-rules/stylelint",
]
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@
"rxjs": "^7.4.0",
"semver": "^7.3.5",
"spdx-satisfies": "^5.0.1",
"stylelint": "^15.1.0",
"supports-color": "9.3.1",
"terser": "^5.14.1",
"ts-node": "^10.8.1",
Expand Down
Loading

0 comments on commit 378c3df

Please sign in to comment.