UNPKG

@fluentui/eslint-plugin

Version:

ESLint configuration and custom rules for Fluent UI

305 lines (270 loc) 11.2 kB
// @ts-check const fs = require('fs-extra'); const path = require('path'); const jju = require('jju'); // eslint-disable-next-line import/no-extraneous-dependencies const { FsTree } = require('nx/src/generators/tree'); // eslint-disable-next-line import/no-extraneous-dependencies const { readProjectConfiguration } = require('@nx/devkit'); /** * @typedef {{root: string, name: string}} Options * @typedef {{name: string, version: string, dependencies: {[key: string]: string}}} PackageJson * @typedef {import("@nx/devkit").WorkspaceJsonConfiguration} WorkspaceJsonConfiguration */ // FIXME: this is not ok (to depend on nx packages within this plugin - redo) /** * Gets project metadata from monorepo source of truth which is `project.json` per project * @param {Options} options * @returns {import('@nx/devkit').ProjectConfiguration} */ function getProjectMetadata(options) { /** * @type {import('@nx/devkit').Tree} */ const tree = new FsTree(options.root, false); return readProjectConfiguration(tree, options.name); } const testFiles = [ '**/*{.,-}{test,spec,e2e,cy}.{ts,tsx}', '**/{test,tests}/**', '**/testUtilities.{ts,tsx}', '**/common/{isConformant,snapshotSerializers}.{ts,tsx}', './e2e/**', ]; const docsFiles = ['**/*Page.tsx', '**/{docs,demo}/**', '**/*.doc.{ts,tsx}']; const storyFiles = ['**/*.stories.tsx', '**/*.stories.ts']; const configFiles = [ './just.config.ts', './cypress.config.ts', './gulpfile.ts', './*.js', './.*.js', './config/**', './scripts/**', './tasks/**', ]; /** * Whether linting is running in context of lint-staged (which should disable rules requiring * type info due to their significant perf penalty). */ const isLintStaged = /pre-commit|lint-staged/.test(process.argv[1]); // Regular expression parts for the naming convention rule const camelCase = '[a-z][a-zA-Z\\d]*'; // must start with lowercase letter const pascalCase = '[A-Z][a-zA-Z\\d]*'; // must start with uppercase letter const camelOrPascalCase = '[a-zA-Z][a-zA-Z\\d]*'; // must start with letter const upperCase = '[A-Z][A-Z\\d]*(_[A-Z\\d]*)*'; // must start with letter, no consecutive underscores const camelOrPascalOrUpperCase = `(${camelOrPascalCase}|${upperCase})`; const builtins = '^(any|Number|number|String|string|Boolean|boolean|Undefined|undefined)$'; module.exports = { /** Test-related files */ testFiles, /** Doc-related files, not including examples */ docsFiles, /** Files for build configuration */ configFiles, /** * Files which may reference `devDependencies`: * - tests * - docs (excluding v8 examples) * - config/build * - stories, for now * - may need to reconsider for converged components depending on website approach * - the stories suffix is also used for storywright stories in `vr-tests` */ devDependenciesFiles: [...testFiles, ...docsFiles, ...configFiles, ...storyFiles], /** Storybook stories */ storyFiles, /** * Whether linting is running in context of lint-staged (which should disable rules requiring * type info due to their significant perf penalty). */ isLintStaged, /** * Returns a rule configuration for [`@typescript-eslint/naming-convention`](https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/naming-convention.md). * This provides the ability to override *only* the interface rule without having to repeat or * lose the rest of the (very complicated) config. * @param {{prefixInterface: boolean}} config - Whether to prefix interfaces with I * @returns {import("eslint").Linter.RulesRecord} */ getNamingConventionRule: (config = { prefixInterface: false }) => ({ '@typescript-eslint/naming-convention': [ 'error', { selector: 'function', format: ['camelCase'], leadingUnderscore: 'allow' }, { selector: 'method', modifiers: ['private'], format: ['camelCase'], leadingUnderscore: 'require' }, { selector: 'method', modifiers: ['protected'], format: ['camelCase'], leadingUnderscore: 'allow' }, // This will also pick up default-visibility methods and methods on plain objects, // which is not really what we want, but there's not a good way around it. { selector: 'method', modifiers: ['public'], format: null, // camelCase, optional UNSAFE_ prefix to handle deprecated React methods custom: { regex: `^(UNSAFE_)?${camelCase}$`, match: true }, }, { selector: ['function', 'variable'], modifiers: ['exported'], format: null, // Allow the _unstable suffix for exported hooks filter: { regex: `^(use|render)${pascalCase}_unstable$`, match: true }, }, { selector: 'typeLike', format: ['PascalCase'], leadingUnderscore: 'forbid' }, { selector: 'interface', format: ['PascalCase'], ...(config.prefixInterface ? { prefix: ['I'] } : { custom: { regex: '^I[A-Z]', match: false } }), }, // Ignore properties that require quotes - https://typescript-eslint.io/rules/naming-convention/#ignore-properties-that-require-quotes { selector: [ 'classProperty', 'objectLiteralProperty', 'typeProperty', 'classMethod', 'objectLiteralMethod', 'typeMethod', 'accessor', 'enumMember', ], format: null, modifiers: ['requiresQuotes'], }, { selector: 'default', format: ['camelCase', 'PascalCase', 'UPPER_CASE'], leadingUnderscore: 'allow', // Allow leading and optional trailing __ // (the rest of the regex just enforces the same casing constraint listed above) filter: { regex: `^__${camelOrPascalOrUpperCase}(__)?$`, match: false }, // Ban names overlapping with built-in types. custom: { regex: builtins, match: false }, // An alternative way to set up this rule is set `format: null` and pass a single custom // regex which matches absolutely everything. However, this leads to unhelpful error messages: // "Variable name `whatever` must match the RegExp: /someAbsurdlyLongUnreadableRegex/" // For reference in case we ever want this anyway: // format: null, // custom: { // regex: `(?!${builtins})^(_?${camelOrPascalOrUpperCase}|__${camelOrPascalOrUpperCase}(__)?)$`, // match: true // } }, ], }), /** * Rules requiring type information should be defined in the `overrides` section since they must * only run on TS files included in a tsconfig.json (generally those files under `src`), and they * require some extra configuration. They should be disabled entirely when running lint-staged * due to their significant perf penalty. (Any violations checked in will be caught in CI.) * @param {import("eslint").Linter.RulesRecord} rules - Rules to enable for TS files * @param {string} [tsconfigPath] - Path to tsconfig, default `path.join(process.cwd()), 'tsconfig.json')` * @returns {import("eslint").Linter.ConfigOverride[]} A single-entry array with a config for TS files if * *not* running lint-staged (or empty array for lint-staged) */ getTypeInfoRuleOverrides: (rules, tsconfigPath = path.join(process.cwd(), 'tsconfig.json')) => { if (isLintStaged) { return []; } // Type info-dependent rules must only apply to TS files included in a project. // Usually this is files under src, but check the tsconfig to verify. const tsGlob = '**/*.{ts,tsx}'; if (!fs.existsSync(tsconfigPath)) { return []; } /** * Note that this approach only accounts for a single level of extends. * - JJU is used for tsconfig parsing because Typescript configs support JS comments (JSON5 "standard") * * @type {{ extends: string; include: string[]; exclude: string[]; compilerOptions: Record<string,unknown>; references?: Array<{path:string}> }} */ const tsconfig = jju.parse(fs.readFileSync(tsconfigPath).toString()); // vNext setup - if project is using solution TS style config (process references) if (tsconfig.references) { return [ { files: [tsGlob], parserOptions: { project: tsconfig.references.map(refConfig => path.join(process.cwd(), refConfig.path)), }, rules, }, ]; } // v8.v0 setup let tsFiles = [`src/${tsGlob}`]; if (tsconfig.include) { tsFiles = tsconfig.include.map(includePath => `${includePath.replace(/\*.*/, '')}/${tsGlob}`); } else if (tsconfig.compilerOptions && tsconfig.compilerOptions.rootDir) { tsFiles = [`${tsconfig.compilerOptions.rootDir}/${tsGlob}`]; } // properly resolve invalid slashes in path and preserve initial relative `./` used in tsconfigs tsFiles = tsFiles.map(fileGlob => { const isRelativePath = !path.isAbsolute(fileGlob); const normalized = path.normalize(fileGlob); if (isRelativePath) { return './' + normalized; } return normalized; }); return [ { files: tsFiles, parserOptions: { project: tsconfigPath, }, rules, excludedFiles: tsconfig.exclude, }, ]; }, /** Finds the root folder of the git repo */ findGitRoot: () => { let cwd = process.cwd(); const root = path.parse(cwd).root; while (cwd !== root) { // .git is usually a folder, but it's a file in worktrees if (fs.existsSync(path.join(cwd, '.git'))) { break; } cwd = path.dirname(cwd); } return cwd; }, /** * Gets package.json of provided package name. * @param {Options} options Takes provided root folder of git repo and package name. * @returns {PackageJson} package.json file of the provided package name. */ getPackageJson: (/** @type {Options} */ options) => { const projectMetaData = getProjectMetadata(options); const packagePath = path.join(options.root, projectMetaData.root); /** @type {PackageJson} */ const packageJson = fs.readJSONSync(path.join(packagePath, 'package.json')); return packageJson; }, /** * Gets a set of v9 packages that are currently being exported as unstable from @fluentui/react-components. * @param {string} root folder of git repo. * @returns {Set<string>} Returns a set of v9 packages that are currently unstable. */ getV9UnstablePackages: (/** @type {string} */ root) => { const v9ProjectMetaData = getProjectMetadata({ root, name: 'react-components' }); const v9PackagePath = path.join(root, v9ProjectMetaData.sourceRoot ?? '', 'unstable', 'index.ts'); const unstableV9Packages = new Set(); fs.readFileSync(v9PackagePath) .toString() .split(' ') .forEach(str => { if (str.includes('@fluentui')) { const pkgName = str.split(';')[0].slice(1, -1); unstableV9Packages.add(pkgName); } }); return unstableV9Packages; }, };