diff --git a/.cspell.json b/.cspell.json index 203763b59c8..898e1093251 100644 --- a/.cspell.json +++ b/.cspell.json @@ -15,6 +15,7 @@ "**/node_modules/**", "packages/website/.docusaurus/**", "packages/website/build/**", + "packages/website/src/vendor/**", "yarn.lock" ], "dictionaries": [ @@ -71,6 +72,7 @@ "IIFEs", "linebreaks", "markdownlint", + "lzstring", "necroing", "nocheck", "nullish", @@ -99,6 +101,7 @@ "transpiled", "transpiles", "transpiling", + "tsvfs", "tsconfigs", "tsutils", "typedef", diff --git a/.eslintignore b/.eslintignore index d47d9f2f23e..b9323511dcb 100644 --- a/.eslintignore +++ b/.eslintignore @@ -6,11 +6,15 @@ fixtures shared-fixtures coverage __snapshots__ +.docusaurus +build packages/eslint-plugin-tslint/tests packages/website/**/*.js packages/website/**/*.d.ts +packages/website-eslint/**/*.js +packages/website-eslint/**/*.d.ts # Files copied as part of the build packages/types/src/ast-spec.ts diff --git a/.eslintrc.js b/.eslintrc.js index 303f8d18b2a..e131f7cb8f2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -205,7 +205,7 @@ module.exports = { 'jest/no-deprecated-functions': 'error', }, }, - // test utility scripts + // test utility scripts and website js files { files: ['tests/**/*.js'], rules: { diff --git a/.gitignore b/.gitignore index b9ffa3b3dc8..c455b1df3ce 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ yarn-error.log* packages/website/.docusaurus packages/website/.cache-loader packages/website/build +packages/website/static/sandbox # Runtime data pids diff --git a/.prettierignore b/.prettierignore index 3b8eebd1684..8c4dce359d7 100644 --- a/.prettierignore +++ b/.prettierignore @@ -18,6 +18,7 @@ CHANGELOG.md packages/website/.docusaurus packages/website/build +packages/website/src/vendor # TODO - remove this once prettier supports TS4.1 packages/scope-manager/tests/fixtures/type-declaration/literal-type1.ts diff --git a/package.json b/package.json index e68ae502738..fe8d5e25da3 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "format": "prettier --write \"./**/*.{ts,tsx,js,jsx,json,md,css}\"", "generate-contributors": "yarn ts-node --transpile-only ./tools/generate-contributors.ts && yarn all-contributors generate", "generate-sponsors": "yarn ts-node --transpile-only ./tools/generate-sponsors.ts", + "generate-website-dts": "yarn ts-node --transpile-only ./tools/generate-website-dts.ts", "lint-fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix", "lint-markdown-fix": "yarn lint-markdown --fix", "lint-markdown": "markdownlint \"**/*.md\" --config=.markdownlint.json --ignore-path=.markdownlintignore", @@ -60,7 +61,6 @@ "@babel/types": "^7.16.0", "@commitlint/cli": "^15.0.0", "@commitlint/config-conventional": "^15.0.0", - "@commitlint/config-lerna-scopes": "^15.0.0", "@nrwl/cli": "13.0.2", "@nrwl/nx-cloud": "12.5.1", "@nrwl/tao": "13.0.2", diff --git a/packages/eslint-plugin/src/util/getWrappingFixer.ts b/packages/eslint-plugin/src/util/getWrappingFixer.ts index 00c00e74882..d5efa8bfca7 100644 --- a/packages/eslint-plugin/src/util/getWrappingFixer.ts +++ b/packages/eslint-plugin/src/util/getWrappingFixer.ts @@ -1,10 +1,10 @@ import { AST_NODE_TYPES, TSESLint, + ASTUtils, TSESTree, } from '@typescript-eslint/experimental-utils'; import { SourceCode } from '@typescript-eslint/experimental-utils/src/ts-eslint'; -import * as util from '../util'; interface WrappingFixerParams { /** Source code. */ @@ -57,7 +57,7 @@ export function getWrappingFixer( if (isWeakPrecedenceParent(node)) { // we wrapped the node in some expression which very likely has a different precedence than original wrapped node // let's wrap the whole expression in parens just in case - if (!util.isParenthesized(node, sourceCode)) { + if (!ASTUtils.isParenthesized(node, sourceCode)) { code = `(${code})`; } } diff --git a/packages/website-eslint/package.json b/packages/website-eslint/package.json new file mode 100644 index 00000000000..3749fa51a13 --- /dev/null +++ b/packages/website-eslint/package.json @@ -0,0 +1,36 @@ +{ + "name": "@typescript-eslint/website-eslint", + "version": "5.4.0", + "private": true, + "description": "ESLint which works in browsers.", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "types": "types/index.d.ts", + "main": "dist/index.js", + "files": [ + "dist" + ], + "scripts": { + "build": "rollup --config=rollup.config.js", + "format": "prettier --write \"./**/*.{ts,js,json,md}\" --ignore-path ../../.prettierignore" + }, + "dependencies": { + "@typescript-eslint/experimental-utils": "^5.4.0", + "@typescript-eslint/types": "^5.4.0" + }, + "devDependencies": { + "eslint": "*", + "rollup": "^2.59.0", + "semver": "^7.3.5", + "@rollup/plugin-commonjs": "^21.0.1", + "@rollup/plugin-json": "^4.1.0", + "@rollup/plugin-node-resolve": "^13.0.6", + "@rollup/pluginutils": "^3.1.0", + "@typescript-eslint/eslint-plugin": "5.4.0", + "@typescript-eslint/parser": "5.4.0", + "@typescript-eslint/scope-manager": "5.4.0", + "@typescript-eslint/typescript-estree": "5.4.0", + "@typescript-eslint/visitor-keys": "5.4.0" + } +} diff --git a/packages/website-eslint/project.json b/packages/website-eslint/project.json new file mode 100644 index 00000000000..ac6ff89fcb8 --- /dev/null +++ b/packages/website-eslint/project.json @@ -0,0 +1,5 @@ +{ + "root": "packages/website-eslint", + "type": "library", + "implicitDependencies": [] +} diff --git a/packages/website-eslint/rollup-plugin/replace.js b/packages/website-eslint/rollup-plugin/replace.js new file mode 100644 index 00000000000..b8f3143c905 --- /dev/null +++ b/packages/website-eslint/rollup-plugin/replace.js @@ -0,0 +1,94 @@ +const path = require('path'); +const Module = require('module'); +const rollupPluginUtils = require('@rollup/pluginutils'); +const MagicString = require('magic-string'); + +function toAbsolute(id) { + return id.startsWith('./') ? path.resolve(id) : require.resolve(id); +} + +function log(opts, message, type = 'info') { + if (opts.verbose) { + console.log('rollup-plugin-replace > [' + type + ']', message); + } +} + +function createMatcher(it) { + if (typeof it === 'function') { + return it; + } else { + return rollupPluginUtils.createFilter(it); + } +} + +module.exports = (options = {}) => { + const aliasesCache = new Map(); + const aliases = (options.alias || []).map(item => { + return { + match: item.match, + matcher: createMatcher(item.match), + target: item.target, + absoluteTarget: toAbsolute(item.target), + }; + }); + const replaces = (options.replace || []).map(item => { + return { + match: item.match, + test: item.test, + replace: + typeof item.replace === 'string' ? () => item.replace : item.replace, + + matcher: createMatcher(item.match), + }; + }); + + return { + name: 'rollup-plugin-replace', + resolveId(id, importerPath) { + const importeePath = + id.startsWith('./') || id.startsWith('../') + ? Module.createRequire(importerPath).resolve(id) + : id; + + let result = aliasesCache.get(importeePath); + if (result) { + return result; + } + + result = aliases.find(item => item.matcher(importeePath)); + if (result) { + aliasesCache.set(importeePath, result.absoluteTarget); + log(options, `${importeePath} as ${result.target}`, 'resolve'); + return result.absoluteTarget; + } + + return null; + }, + transform(code, id) { + let hasReplacements = false; + let magicString = new MagicString(code); + + replaces.forEach(item => { + if (item.matcher && !item.matcher(id)) { + return; + } + + let match = item.test.exec(code); + let start, end; + while (match) { + hasReplacements = true; + start = match.index; + end = start + match[0].length; + magicString.overwrite(start, end, item.replace(match)); + match = item.test.global ? item.test.exec(code) : null; + } + }); + + if (!hasReplacements) { + return; + } + log(options, id, 'replace'); + return { code: magicString.toString() }; + }, + }; +}; diff --git a/packages/website-eslint/rollup.config.js b/packages/website-eslint/rollup.config.js new file mode 100644 index 00000000000..f1edc288512 --- /dev/null +++ b/packages/website-eslint/rollup.config.js @@ -0,0 +1,103 @@ +import commonjs from '@rollup/plugin-commonjs'; +import json from '@rollup/plugin-json'; +import resolve from '@rollup/plugin-node-resolve'; +const replace = require('./rollup-plugin/replace'); + +module.exports = { + input: 'src/linter/linter.js', + output: { + format: 'amd', + interop: 'auto', + freeze: false, + file: 'dist/index.js', + }, + external: ['vs/language/typescript/tsWorker'], + plugins: [ + replace({ + // verbose: true, + alias: [ + { + // those files should be omitted, we do not want them to be exposed to web + match: [ + /eslint\/lib\/(rule-tester|eslint|cli-engine|init)\//u, + /eslint\/lib\/cli\.js$/, + /experimental-utils\/dist\/eslint-utils\/RuleTester\.js$/, + /experimental-utils\/dist\/ts-eslint\/CLIEngine\.js$/, + /experimental-utils\/dist\/ts-eslint\/RuleTester\.js$/, + /typescript-estree\/dist\/create-program\/createWatchProgram\.js/, + /typescript-estree\/dist\/create-program\/createProjectProgram\.js/, + /typescript-estree\/dist\/create-program\/createIsolatedProgram\.js/, + /experimental-utils\/dist\/ts-eslint\/ESLint\.js/, + // 'eslint/lib/shared/ajv.js', + // 'eslint/lib/shared/runtime-info.js', + ], + target: './src/mock/empty.js', + }, + { + // use window.ts instead of bundling typescript + match: /typescript$/u, + target: './src/mock/typescript.js', + }, + { + // assert for web + match: /^assert$/u, + target: './src/mock/assert.js', + }, + { + // path for web + match: /^path$/u, + target: './src/mock/path.js', + }, + { + // util for web + match: /^util$/u, + target: './src/mock/util.js', + }, + { + // semver simplified, solve issue with circular dependencies + match: /semver$/u, + target: './src/mock/semver.js', + }, + ], + replace: [ + { + // we do not want dynamic imports + match: /eslint\/lib\/linter\/rules\.js$/u, + test: /require\(this\._rules\[ruleId\]\)/u, + replace: 'null', + }, + { + // esquery has both browser and node versions, we are bundling browser version that has different export + test: /esquery\.parse\(/u, + replace: 'esquery.default.parse(', + }, + { + // esquery has both browser and node versions, we are bundling browser version that has different export + test: /esquery\.matches\(/u, + replace: 'esquery.default.matches(', + }, + { + // replace all process.env.NODE_DEBUG with false + test: /process\.env\.NODE_DEBUG/u, + replace: 'false', + }, + { + // replace all process.env.TIMING with false + test: /process\.env\.TIMING/u, + replace: 'false', + }, + { + // replace all process.env.IGNORE_TEST_WIN32 with true + test: /process\.env\.IGNORE_TEST_WIN32/u, + replace: 'true', + }, + ], + }), + resolve({ + browser: true, + preferBuiltins: false, + }), + commonjs(), + json({ preferConst: true }), + ], +}; diff --git a/packages/website-eslint/src/linter/CompilerHost.js b/packages/website-eslint/src/linter/CompilerHost.js new file mode 100644 index 00000000000..2b7fdec25d0 --- /dev/null +++ b/packages/website-eslint/src/linter/CompilerHost.js @@ -0,0 +1,48 @@ +import { getDefaultLibFileName } from 'typescript'; + +export class CompilerHost { + constructor(files, sourceFiles) { + this.files = files; + this.sourceFiles = sourceFiles; + } + + fileExists(name) { + return !!this.files[name]; + } + + getCanonicalFileName(name) { + return name; + } + + getCurrentDirectory() { + return '/'; + } + + getDirectories() { + return []; + } + + getDefaultLibFileName(options) { + return '/' + getDefaultLibFileName(options); + } + + getNewLine() { + return '\n'; + } + + useCaseSensitiveFileNames() { + return true; + } + + writeFile() { + return null; + } + + readFile(name) { + return this.files[name]; + } + + getSourceFile(name) { + return this.sourceFiles[name]; + } +} diff --git a/packages/website-eslint/src/linter/config.js b/packages/website-eslint/src/linter/config.js new file mode 100644 index 00000000000..06e9ec28b3e --- /dev/null +++ b/packages/website-eslint/src/linter/config.js @@ -0,0 +1,24 @@ +export const extra = { + code: '', + comment: true, + comments: [], + createDefaultProgram: false, + debugLevel: new Set(), + errorOnTypeScriptSyntacticAndSemanticIssues: false, + errorOnUnknownASTType: false, + extraFileExtensions: [], + filePath: '', + jsx: false, + loc: true, + log: console.log, + preserveNodeMaps: true, + projects: [], + range: true, + strict: false, + tokens: [], + tsconfigRootDir: '/', + EXPERIMENTAL_useSourceOfProjectReferenceRedirect: false, + singleRun: false, + programs: null, + moduleResolver: '', +}; diff --git a/packages/website-eslint/src/linter/create-ast-program.js b/packages/website-eslint/src/linter/create-ast-program.js new file mode 100644 index 00000000000..ea0444fd249 --- /dev/null +++ b/packages/website-eslint/src/linter/create-ast-program.js @@ -0,0 +1,44 @@ +import { + createProgram, + createSourceFile, + ScriptTarget, + ScriptKind, + JsxEmit, + ModuleKind, +} from 'typescript'; +import { CompilerHost } from './CompilerHost'; + +export function createASTProgram(code, parserOptions) { + const isJsx = !!parserOptions?.ecmaFeatures?.jsx; + const fileName = isJsx ? '/demo.tsx' : '/demo.ts'; + const files = { + [fileName]: code, + }; + const sourceFiles = { + [fileName]: createSourceFile( + fileName, + code, + ScriptTarget.Latest, + true, + isJsx ? ScriptKind.TSX : ScriptKind.TS, + ), + }; + const compilerHost = new CompilerHost(files, sourceFiles); + const compilerOptions = { + noResolve: true, + strict: true, + target: ScriptTarget.Latest, + jsx: isJsx ? JsxEmit.React : undefined, + module: ModuleKind.ES2015, + }; + const program = createProgram( + Object.keys(files), + compilerOptions, + compilerHost, + ); + const ast = program.getSourceFile(fileName); + return { + ast, + program, + }; +} diff --git a/packages/website-eslint/src/linter/linter.js b/packages/website-eslint/src/linter/linter.js new file mode 100644 index 00000000000..4b798674eeb --- /dev/null +++ b/packages/website-eslint/src/linter/linter.js @@ -0,0 +1,50 @@ +import 'vs/language/typescript/tsWorker'; +import { parseForESLint } from './parser'; +import { Linter } from 'eslint'; +import rules from '@typescript-eslint/eslint-plugin/dist/rules'; + +const PARSER_NAME = '@typescript-eslint/parser'; + +export function loadLinter() { + const linter = new Linter(); + let storedAST; + + linter.defineParser(PARSER_NAME, { + parseForESLint(code, options) { + const toParse = parseForESLint(code, options); + storedAST = toParse.ast; + return toParse; + }, // parse(code: string, options: ParserOptions): ParseForESLintResult['ast'] { + // const toParse = parseForESLint(code, options); + // storedAST = toParse.ast; + // return toParse.ast; + // }, + }); + + for (const name of Object.keys(rules)) { + linter.defineRule(`@typescript-eslint/${name}`, rules[name]); + } + + const ruleNames = Array.from(linter.getRules()).map(value => { + return { + name: value[0], + description: value[1]?.meta?.docs?.description, + }; + }); + + return { + ruleNames: ruleNames, + + getAst() { + return storedAST; + }, + + lint(code, parserOptions, rules) { + return linter.verify(code, { + parser: PARSER_NAME, + parserOptions, + rules, + }); + }, + }; +} diff --git a/packages/website-eslint/src/linter/parser.js b/packages/website-eslint/src/linter/parser.js new file mode 100644 index 00000000000..282f67c0da7 --- /dev/null +++ b/packages/website-eslint/src/linter/parser.js @@ -0,0 +1,47 @@ +import { analyze } from '@typescript-eslint/scope-manager/dist/analyze'; +import { visitorKeys } from '@typescript-eslint/visitor-keys/dist/visitor-keys'; +import { astConverter } from '@typescript-eslint/typescript-estree/dist/ast-converter'; +import { createASTProgram } from './create-ast-program.js'; +import { extra } from './config.js'; + +function parseAndGenerateServices(code, options) { + const { ast, program } = createASTProgram(code, options); + const { estree, astMaps } = astConverter( + ast, + { ...extra, code, jsx: options.jsx ?? false }, + true, + ); + + return { + ast: estree, + services: { + hasFullTypeInformation: true, + program, + esTreeNodeToTSNodeMap: astMaps.esTreeNodeToTSNodeMap, + tsNodeToESTreeNodeMap: astMaps.tsNodeToESTreeNodeMap, + }, + }; +} + +export function parseForESLint(code, parserOptions) { + const { ast, services } = parseAndGenerateServices(code, { + ...parserOptions, + jsx: parserOptions.ecmaFeatures?.jsx ?? false, + useJSXTextNode: true, + projectFolderIgnoreList: [], + }); + + const scopeManager = analyze(ast, { + ecmaVersion: + parserOptions.ecmaVersion === 'latest' ? 1e8 : parserOptions.ecmaVersion, + globalReturn: parserOptions.ecmaFeatures?.globalReturn ?? false, + sourceType: parserOptions.sourceType ?? 'script', + }); + + return { + ast, + services, + scopeManager, + visitorKeys, + }; +} diff --git a/packages/website-eslint/src/mock/assert.js b/packages/website-eslint/src/mock/assert.js new file mode 100644 index 00000000000..70cbf7a4fbc --- /dev/null +++ b/packages/website-eslint/src/mock/assert.js @@ -0,0 +1,107 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +// resolves . and .. elements in a path array with directory names there +// must be no slashes, empty elements, or device names (c:\) in the array +// (so also no leading and trailing slashes - it does not distinguish +// relative and absolute paths) +class AssertionError extends Error { + constructor(options) { + super(options); + + this.actual = options.actual; + this.expected = options.expected; + this.operator = options.operator; + if (options.message) { + this.message = options.message; + this.generatedMessage = false; + } else { + this.message = ''; + this.generatedMessage = true; + } + const stackStartFunction = options.stackStartFunction || fail; + if (Error.captureStackTrace) { + Error.captureStackTrace(this, stackStartFunction); + } else { + // non v8 browsers so we can have a stacktrace + const err = new Error(); + if (err.stack) { + let out = err.stack; + + // try to strip useless frames + const fn_name = + typeof stackStartFunction === 'function' + ? stackStartFunction.name + : stackStartFunction.toString(); + const idx = out.indexOf('\n' + fn_name); + if (idx >= 0) { + // once we have located the function frame + // we need to strip out everything before it (and its line) + const next_line = out.indexOf('\n', idx + 1); + out = out.substring(next_line + 1); + } + + this.stack = out; + } + } + } +} + +function fail(actual, expected, message, operator, stackStartFunction) { + throw new AssertionError({ + message: message, + actual: actual, + expected: expected, + operator: operator, + stackStartFunction: stackStartFunction, + }); +} + +function assert(value, message) { + if (!value) { + fail(value, true, message, '==', assert); + } +} +assert.equal = function equal(actual, expected, message) { + if (actual != expected) { + fail(actual, expected, message, '==', equal); + } +}; +assert.strictEqual = function strictEqual(actual, expected, message) { + if (actual !== expected) { + fail(actual, expected, message, '===', strictEqual); + } +}; +assert.notStrictEqual = function notStrictEqual(actual, expected, message) { + if (actual === expected) { + fail(actual, expected, message, '!==', notStrictEqual); + } +}; +assert.notEqual = function notEqual(actual, expected, message) { + if (actual == expected) { + fail(actual, expected, message, '!=', notEqual); + } +}; +assert.assert = assert.ok = assert; +assert.fail = fail; +assert.AssertionError = AssertionError; + +module.exports = assert; diff --git a/packages/website-eslint/src/mock/empty.js b/packages/website-eslint/src/mock/empty.js new file mode 100644 index 00000000000..ff8b4c56321 --- /dev/null +++ b/packages/website-eslint/src/mock/empty.js @@ -0,0 +1 @@ +export default {}; diff --git a/packages/website-eslint/src/mock/path.js b/packages/website-eslint/src/mock/path.js new file mode 100644 index 00000000000..3d04551e2e7 --- /dev/null +++ b/packages/website-eslint/src/mock/path.js @@ -0,0 +1,244 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +// resolves . and .. elements in a path array with directory names there +// must be no slashes, empty elements, or device names (c:\) in the array +// (so also no leading and trailing slashes - it does not distinguish +// relative and absolute paths) +function normalizeArray(parts, allowAboveRoot) { + // if the path tries to go above the root, `up` ends up > 0 + let up = 0; + for (let i = parts.length - 1; i >= 0; i--) { + const last = parts[i]; + if (last === '.') { + parts.splice(i, 1); + } else if (last === '..') { + parts.splice(i, 1); + up++; + } else if (up) { + parts.splice(i, 1); + up--; + } + } + + // if the path is allowed to go above the root, restore leading ..s + if (allowAboveRoot) { + for (; up--; up) { + parts.unshift('..'); + } + } + + return parts; +} + +// Split a filename into [root, dir, basename, ext], unix version +// 'root' is just a slash, or nothing. +const splitPathRe = + /^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/; +const splitPath = function (filename) { + return splitPathRe.exec(filename).slice(1); +}; + +// path.resolve([from ...], to) +// posix version +export function resolve() { + let resolvedPath = '', + resolvedAbsolute = false; + + for (let i = arguments.length - 1; i >= -1 && !resolvedAbsolute; i--) { + const path = i >= 0 ? arguments[i] : '/'; + + // Skip empty and invalid entries + if (typeof path !== 'string') { + throw new TypeError('Arguments to path.resolve must be strings'); + } else if (!path) { + continue; + } + + resolvedPath = path + '/' + resolvedPath; + resolvedAbsolute = path.charAt(0) === '/'; + } + + // At this point the path should be resolved to a full absolute path, but + // handle relative paths to be safe (might happen when process.cwd() fails) + + // Normalize the path + resolvedPath = normalizeArray( + filter(resolvedPath.split('/'), function (p) { + return !!p; + }), + !resolvedAbsolute, + ).join('/'); + + return (resolvedAbsolute ? '/' : '') + resolvedPath || '.'; +} + +// path.normalize(path) +// posix version +export function normalize(path) { + let isPathAbsolute = isAbsolute(path), + trailingSlash = substr(path, -1) === '/'; + + // Normalize the path + path = normalizeArray( + filter(path.split('/'), function (p) { + return !!p; + }), + !isPathAbsolute, + ).join('/'); + + if (!path && !isPathAbsolute) { + path = '.'; + } + if (path && trailingSlash) { + path += '/'; + } + + return (isPathAbsolute ? '/' : '') + path; +} + +// posix version +export function isAbsolute(path) { + return path.charAt(0) === '/'; +} + +// posix version +export function join() { + const paths = Array.prototype.slice.call(arguments, 0); + return normalize( + filter(paths, function (p, index) { + if (typeof p !== 'string') { + throw new TypeError('Arguments to path.join must be strings'); + } + return p; + }).join('/'), + ); +} + +// path.relative(from, to) +// posix version +export function relative(from, to) { + from = resolve(from).substr(1); + to = resolve(to).substr(1); + + function trim(arr) { + let start = 0; + for (; start < arr.length; start++) { + if (arr[start] !== '') break; + } + + var end = arr.length - 1; + for (; end >= 0; end--) { + if (arr[end] !== '') break; + } + + if (start > end) return []; + return arr.slice(start, end - start + 1); + } + + const fromParts = trim(from.split('/')); + const toParts = trim(to.split('/')); + + const length = Math.min(fromParts.length, toParts.length); + let samePartsLength = length; + for (let i = 0; i < length; i++) { + if (fromParts[i] !== toParts[i]) { + samePartsLength = i; + break; + } + } + + const outputParts = []; + for (let i = samePartsLength; i < fromParts.length; i++) { + outputParts.push('..'); + } + + outputParts = outputParts.concat(toParts.slice(samePartsLength)); + + return outputParts.join('/'); +} + +export var sep = '/'; +export var delimiter = ':'; + +export function dirname(path) { + const result = splitPath(path); + const root = result[0]; + let dir = result[1]; + + if (!root && !dir) { + // No dirname whatsoever + return '.'; + } + + if (dir) { + // It has a dirname, strip trailing slash + dir = dir.substr(0, dir.length - 1); + } + + return root + dir; +} + +export function basename(path, ext) { + let f = splitPath(path)[2]; + // TODO: make this comparison case-insensitive on windows? + if (ext && f.substr(-1 * ext.length) === ext) { + f = f.substr(0, f.length - ext.length); + } + return f; +} + +export function extname(path) { + return splitPath(path)[3]; +} + +export default { + extname: extname, + basename: basename, + dirname: dirname, + sep: sep, + delimiter: delimiter, + relative: relative, + join: join, + isAbsolute: isAbsolute, + normalize: normalize, + resolve: resolve, +}; + +function filter(xs, f) { + if (xs.filter) return xs.filter(f); + const res = []; + for (let i = 0; i < xs.length; i++) { + if (f(xs[i], i, xs)) res.push(xs[i]); + } + return res; +} + +// String.prototype.substr - negative index don't work in IE8 +const substr = + 'ab'.substr(-1) === 'b' + ? function (str, start, len) { + return str.substr(start, len); + } + : function (str, start, len) { + if (start < 0) start = str.length + start; + return str.substr(start, len); + }; diff --git a/packages/website-eslint/src/mock/semver.js b/packages/website-eslint/src/mock/semver.js new file mode 100644 index 00000000000..ba292927fe9 --- /dev/null +++ b/packages/website-eslint/src/mock/semver.js @@ -0,0 +1,4 @@ +import satisfies from 'semver/functions/satisfies'; +import major from 'semver/functions/major'; + +export { satisfies, major }; diff --git a/packages/website-eslint/src/mock/typescript.js b/packages/website-eslint/src/mock/typescript.js new file mode 100644 index 00000000000..324b844294b --- /dev/null +++ b/packages/website-eslint/src/mock/typescript.js @@ -0,0 +1 @@ +module.exports = window.ts; diff --git a/packages/website-eslint/src/mock/util.js b/packages/website-eslint/src/mock/util.js new file mode 100644 index 00000000000..3e5cd5e0e71 --- /dev/null +++ b/packages/website-eslint/src/mock/util.js @@ -0,0 +1,7 @@ +const util = {}; + +util.inspect = function (value) { + return value; +}; + +export default util; diff --git a/packages/website-eslint/types/index.d.ts b/packages/website-eslint/types/index.d.ts new file mode 100644 index 00000000000..eaa86160cba --- /dev/null +++ b/packages/website-eslint/types/index.d.ts @@ -0,0 +1,34 @@ +import type { TSESLint } from '@typescript-eslint/experimental-utils'; +import type { ParserOptions } from '@typescript-eslint/types'; + +export type LintMessage = TSESLint.Linter.LintMessage; +export type RuleFix = TSESLint.RuleFix; +export type RulesRecord = TSESLint.Linter.RulesRecord; +export type RuleEntry = TSESLint.Linter.RuleEntry; +export type ParseForESLintResult = TSESLint.Linter.ESLintParseResult; +export type ESLintAST = ParseForESLintResult['ast']; + +export interface WebLinter { + ruleNames: { name: string; description?: string }[]; + + getAst(): ESLintAST; + + lint( + code: string, + parserOptions: ParserOptions, + rules?: RulesRecord, + ): LintMessage[]; +} + +export interface LinterLoader { + loadLinter(): WebLinter; +} + +export type { TSESTree } from '@typescript-eslint/types'; + +export type { + DebugLevel, + EcmaVersion, + ParserOptions, + SourceType, +} from '@typescript-eslint/types'; diff --git a/packages/website/.eslintrc.js b/packages/website/.eslintrc.js index d49c1a3fab6..8b30fb62498 100644 --- a/packages/website/.eslintrc.js +++ b/packages/website/.eslintrc.js @@ -8,7 +8,11 @@ module.exports = { plugins: ['jsx-a11y', 'react', 'react-hooks'], overrides: [ { - files: ['./src/pages/*.tsx'], + files: [ + './src/pages/*.tsx', + './src/components/**/*.tsx', + './src/components/hooks/*.ts', + ], rules: { 'import/no-default-export': 'off', }, @@ -17,7 +21,8 @@ module.exports = { rules: { 'react/jsx-no-target-blank': 'off', 'react/no-unescaped-entities': 'off', - 'react-hooks/exhaustive-deps': 'error', + '@typescript-eslint/internal/prefer-ast-types-enum': 'off', + 'react-hooks/exhaustive-deps': 'off', // TODO: enable it later }, settings: { react: { diff --git a/packages/website/docusaurus.config.js b/packages/website/docusaurus.config.js index 2c3713c2f90..8eee26a2896 100644 --- a/packages/website/docusaurus.config.js +++ b/packages/website/docusaurus.config.js @@ -25,6 +25,7 @@ const config = { projectName: 'typescript-eslint', clientModules: [require.resolve('./src/clientModules.js')], plugins: [ + require.resolve('./webpack.plugin'), '@docusaurus/plugin-debug', [ '@docusaurus/theme-classic', @@ -100,6 +101,12 @@ const config = { label: 'Rules', position: 'left', }, + { + to: 'play', + activeBasePath: 'play', + position: 'right', + label: 'Playground', + }, { href: githubUrl, position: 'right', diff --git a/packages/website/package.json b/packages/website/package.json index a2278425623..afaee5506ca 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -20,18 +20,14 @@ "@docusaurus/theme-classic": "^2.0.0-beta.9", "@docusaurus/theme-search-algolia": "^2.0.0-beta.9", "@mdx-js/react": "1.6.22", - "@typescript-eslint/eslint-plugin": "5.4.0", - "@typescript-eslint/parser": "5.4.0", - "@typescript-eslint/scope-manager": "5.4.0", - "@typescript-eslint/types": "5.4.0", - "@typescript-eslint/typescript-estree": "5.4.0", - "@typescript-eslint/visitor-keys": "5.4.0", + "@typescript-eslint/website-eslint": "5.4.0", "clsx": "^1.1.1", "eslint": "*", "konamimojisplosion": "^0.5.1", "react": "^17.0.2", "react-dom": "^17.0.2", "remark-docusaurus-tabs": "^0.2.0", + "lzstring.ts": "^2.0.2", "typescript": "*" }, "devDependencies": { @@ -43,7 +39,9 @@ "eslint-plugin-react": "^7.27.1", "eslint-plugin-react-hooks": "^4.3.0", "globby": "^11.0.4", - "webpack": "^5.61.0" + "monaco-editor": "^0.30.1", + "webpack": "^5.64.0", + "copy-webpack-plugin": "^9.1.0" }, "browserslist": { "production": [ diff --git a/packages/website/src/components/OptionsSelector.module.css b/packages/website/src/components/OptionsSelector.module.css new file mode 100644 index 00000000000..5ff62a8c5b0 --- /dev/null +++ b/packages/website/src/components/OptionsSelector.module.css @@ -0,0 +1,81 @@ +.optionLabel { + cursor: pointer; +} + +.optionItem, +.optionLabel { + align-items: center; + display: flex; + flex: 0 0 1.5rem; + flex-direction: row; + font-size: 0.75rem; + margin: 0 0; + padding: 0.4rem 0.8rem; + transition: background-color var(--ifm-transition-fast) + var(--ifm-transition-timing-default), + color var(--ifm-transition-fast) var(--ifm-transition-timing-default); + color: var(--ifm-font-color-secondary); + justify-content: space-between; + border: none; + background: transparent; + font-family: var(--ifm-font-family-base); + box-sizing: border-box; + line-height: var(--ifm-line-height-base); +} + +.optionLabel:hover { + background-color: var(--ifm-color-emphasis-100); + color: var(--ifm-font-color-primary); +} + +.clickableIcon { + cursor: pointer; +} + +.clickableIcon:hover { + color: var(--ifm-color-primary); +} + +.optionInput { + display: block; + width: 90%; + padding: 0.4rem 0.6rem; + line-height: 1; + font-size: 0.8rem; + font-weight: 500; + font-family: inherit; + border-radius: 6px; + -webkit-appearance: none; + color: var(--ifm-font-color-secondary); + border: 1px solid var(--ifm-color-emphasis-100); + background: var(--ifm-color-emphasis-200); + transition: border 0.3s ease; +} +.optionInput::placeholder { + color: var(--ifm-color-emphasis-700); +} +.optionInput:focus { + outline: none; + border-color: var(--ifm-color-primary); +} + +.optionSelect { + line-height: 1; + font-size: 0.8rem; + font-weight: 500; + border-radius: 6px; + font-family: inherit; + width: 50%; + box-shadow: none; + background-image: none; + padding: 0.4rem 0.6rem; + appearance: none; + color: var(--ifm-font-color-secondary); + border: 1px solid var(--ifm-color-emphasis-100); + background: var(--ifm-color-emphasis-200); + transition: border 0.3s ease; +} +.optionSelect:focus { + outline: none; + border-color: var(--ifm-color-primary); +} diff --git a/packages/website/src/components/OptionsSelector.tsx b/packages/website/src/components/OptionsSelector.tsx new file mode 100644 index 00000000000..71f83a95902 --- /dev/null +++ b/packages/website/src/components/OptionsSelector.tsx @@ -0,0 +1,192 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useCallback, useState } from 'react'; + +import ConfigEslint from './config/ConfigEslint'; +import ConfigTypeScript from './config/ConfigTypeScript'; +import Expander from './layout/Expander'; +import Dropdown from './inputs/Dropdown'; +import Checkbox from './inputs/Checkbox'; +import Tooltip from './inputs/Tooltip'; +import EditIcon from './icons/EditIcon'; +import CopyIcon from './icons/CopyIcon'; + +import { createMarkdown } from './lib/markdown'; + +import type { RuleDetails } from './types'; + +import styles from './OptionsSelector.module.css'; + +import type { + CompilerFlags, + ConfigModel, + SourceType, + RulesRecord, +} from './types'; + +export interface OptionsSelectorParams { + readonly ruleOptions: RuleDetails[]; + readonly state: ConfigModel; + readonly setState: (cfg: Partial) => void; + readonly tsVersions: readonly string[]; + readonly isLoading: boolean; +} + +function OptionsSelector({ + ruleOptions, + state, + setState, + tsVersions, + isLoading, +}: OptionsSelectorParams): JSX.Element { + const [eslintModal, setEslintModal] = useState(false); + const [typeScriptModal, setTypeScriptModal] = useState(false); + const [copyLink, setCopyLink] = useState(false); + const [copyMarkdown, setCopyMarkdown] = useState(false); + + const updateTS = useCallback((version: string) => { + setState({ ts: version }); + }, []); + + const updateRules = useCallback((rules: RulesRecord) => { + setState({ rules: rules }); + setEslintModal(false); + }, []); + + const updateTsConfig = useCallback((config: CompilerFlags) => { + setState({ tsConfig: config }); + setTypeScriptModal(false); + }, []); + + const copyLinkToClipboard = useCallback(async () => { + await navigator.clipboard.writeText(document.location.toString()); + setCopyLink(true); + }, []); + + const copyMarkdownToClipboard = useCallback(async () => { + if (isLoading) { + return; + } + await navigator.clipboard.writeText(createMarkdown(state)); + setCopyMarkdown(true); + }, [state, isLoading]); + + const openIssue = useCallback(() => { + if (isLoading) { + return; + } + window + .open( + `https://github.com/typescript-eslint/typescript-eslint/issues/new?body=${encodeURIComponent( + createMarkdown(state), + )}`, + '_blank', + ) + ?.focus(); + }, [state, isLoading]); + + return ( + <> + {state.rules && ruleOptions.length > 0 && ( + + )} + + + + + + + + + + + + + + + + + + + + ); +} + +export default OptionsSelector; diff --git a/packages/website/src/components/Playground.module.css b/packages/website/src/components/Playground.module.css new file mode 100644 index 00000000000..6c5a9fdad71 --- /dev/null +++ b/packages/website/src/components/Playground.module.css @@ -0,0 +1,43 @@ +.options { + width: 20rem; + background: var(--ifm-background-surface-color); + overflow: auto; +} + +.sourceCode { + height: 100%; + width: 50%; + border: 1px solid var(--ifm-color-emphasis-100); +} + +.sourceCodeStandalone { + width: 100%; +} + +.codeBlocks { + display: flex; + flex-direction: row; + height: 100%; + width: calc(100vw - 20rem); +} + +.astViewer { + height: 100%; + width: 50%; + border: 1px solid var(--ifm-color-emphasis-100); + overflow: auto; + background: var(--ifm-background-surface-color); + word-wrap: initial; + white-space: nowrap; + background: var(--code-editor-bg); +} + +.codeContainer { + display: flex; + flex-direction: row; + position: fixed; + width: 100%; + height: calc(100% - var(--ifm-navbar-height)); + top: var(--ifm-navbar-height); + z-index: var(--ifm-z-index-fixed); +} diff --git a/packages/website/src/components/Playground.tsx b/packages/website/src/components/Playground.tsx new file mode 100644 index 00000000000..f0c7903e5f2 --- /dev/null +++ b/packages/website/src/components/Playground.tsx @@ -0,0 +1,106 @@ +import React, { useCallback, useState } from 'react'; +import type Monaco from 'monaco-editor'; +import clsx from 'clsx'; +import useThemeContext from '@theme/hooks/useThemeContext'; + +import styles from './Playground.module.css'; +import Loader from './layout/Loader'; + +import useHashState from './hooks/useHashState'; +import OptionsSelector from './OptionsSelector'; +import ASTViewer from './ast/ASTViewer'; +import { LoadingEditor } from './editor/LoadingEditor'; +import { EditorEmbed } from './editor/EditorEmbed'; +import type { RuleDetails } from './types'; + +import type { TSESTree } from '@typescript-eslint/website-eslint'; + +function Playground(): JSX.Element { + const [state, setState] = useHashState({ + jsx: false, + showAST: false, + sourceType: 'module', + code: '', + ts: process.env.TS_VERSION, + rules: {}, + tsConfig: {}, + }); + const { isDarkTheme } = useThemeContext(); + const [ast, setAST] = useState(); + const [ruleNames, setRuleNames] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [tsVersions, setTSVersion] = useState([]); + const [selectedNode, setSelectedNode] = useState(null); + const [position, setPosition] = useState(null); + + const updateSelectedNode = useCallback( + (node: TSESTree.Node | null) => { + if ( + !node || + !selectedNode || + selectedNode.range[0] !== node.range[0] || + selectedNode.range[1] !== node.range[1] + ) { + setSelectedNode(node); + } + }, + [selectedNode], + ); + + return ( +
+
+ +
+
+
+ {isLoading && } + + setState({ code: code })} + onLoaded={(ruleNames, tsVersions): void => { + setRuleNames(ruleNames); + setTSVersion(tsVersions); + setIsLoading(false); + }} + onSelect={setPosition} + /> +
+ {state.showAST && ( +
+ {ast && ( + + )} +
+ )} +
+
+ ); +} + +export default Playground; diff --git a/packages/website/src/components/ast/ASTViewer.module.css b/packages/website/src/components/ast/ASTViewer.module.css new file mode 100644 index 00000000000..ebb4b3470dc --- /dev/null +++ b/packages/website/src/components/ast/ASTViewer.module.css @@ -0,0 +1,78 @@ +.list, +.subList { + cursor: default; + box-sizing: border-box; + margin: 0; + list-style: none; + padding-left: 1.5rem; + font-family: Consolas, "Courier New", monospace; + font-weight: normal; + font-size: 13px; + font-feature-settings: "liga" 0, "calt" 0; + line-height: 18px; + letter-spacing: 0px; +} + +.nonExpand, +.expand { + border-left: 0.1rem dashed var(--ifm-color-emphasis-200); +} + +.expand.open::before { + content: '+'; +} + +.selected { + background: var(--code-line-decoration); +} + +.expand::before { + content: '-'; + margin-left: -1rem; + width: 1rem; + display: inline-block; +} + +.valueBody { + min-width: 300px; + width: fit-content; +} + +.tokenName { + color: #2aa198; +} + +.propName { + color: #b58900; +} + +.propNumber { + color: #268bd2; +} + +.propEmpty { + color: var(--ifm-color-emphasis-400); +} + +.propRegExp { + color: #b58900; +} + +.propBoolean { + color: #b58900; +} + +.propString { + color: #15aa15; +} + +.hidden { + color: var(--ifm-color-emphasis-400); +} + +.clickable { + cursor: pointer; +} +.clickable:hover { + text-decoration: underline; +} diff --git a/packages/website/src/components/ast/ASTViewer.tsx b/packages/website/src/components/ast/ASTViewer.tsx new file mode 100644 index 00000000000..7c234f91a23 --- /dev/null +++ b/packages/website/src/components/ast/ASTViewer.tsx @@ -0,0 +1,49 @@ +import React, { useEffect, useState } from 'react'; +import styles from './ASTViewer.module.css'; + +import type { TSESTree } from '@typescript-eslint/website-eslint'; +import type { Position } from './types'; + +import { ElementObject } from './Elements'; +import type Monaco from 'monaco-editor'; + +function ASTViewer(props: { + ast: TSESTree.Node | string; + position?: Monaco.Position | null; + onSelectNode: (node: TSESTree.Node | null) => void; +}): JSX.Element { + const [selection, setSelection] = useState(() => + props.position + ? { + line: props.position.lineNumber, + column: props.position.column - 1, + } + : null, + ); + + useEffect(() => { + setSelection( + props.position + ? { + line: props.position.lineNumber, + column: props.position.column - 1, + } + : null, + ); + }, [props.position]); + + return typeof props.ast === 'string' ? ( +
{props.ast}
+ ) : ( +
+ +
+ ); +} + +export default ASTViewer; diff --git a/packages/website/src/components/ast/Elements.tsx b/packages/website/src/components/ast/Elements.tsx new file mode 100644 index 00000000000..9dba9eff491 --- /dev/null +++ b/packages/website/src/components/ast/Elements.tsx @@ -0,0 +1,218 @@ +import React, { + SyntheticEvent, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import clsx from 'clsx'; + +import type { TSESTree } from '@typescript-eslint/website-eslint'; +import type { GenericParams } from './types'; + +import { scrollIntoViewIfNeeded } from '@site/src/components/lib/scroll-into'; +import { + filterRecord, + hasChildInRange, + isArrayInRange, + isEsNode, + isInRange, + isRecord, +} from './selection'; + +import PropertyNameComp from '@site/src/components/ast/PropertyName'; +import PropertyValueComp from '@site/src/components/ast/PropertyValue'; +import styles from '@site/src/components/ast/ASTViewer.module.css'; + +export const PropertyName = React.memo(PropertyNameComp); +export const PropertyValue = React.memo(PropertyValueComp); + +export function ElementArray(props: GenericParams): JSX.Element { + const [isComplex, setIsComplex] = useState(() => + isRecord(props.value), + ); + const [isExpanded, setIsExpanded] = useState( + () => + isComplex || props.value.some(item => isInRange(props.selection, item)), + ); + + useEffect(() => { + setIsComplex( + props.value.some(item => typeof item === 'object' && item !== null), + ); + }, [props.value]); + + useEffect(() => { + if (isComplex && !isExpanded) { + setIsExpanded(isArrayInRange(props.selection, props.value)); + } + }, [props.value, props.selection]); + + return ( +
+ setIsExpanded(!isExpanded)} + /> + [ + {isExpanded ? ( +
+ {props.value.map((item, index) => { + return ( + + ); + })} +
+ ) : !isComplex ? ( + + {props.value.map((item, index) => ( + + {index > 0 && ', '} + + + ))} + + ) : ( + + {props.value.length} {props.value.length > 1 ? 'elements' : 'element'} + + )} + ] +
+ ); +} + +export function ElementObject( + props: GenericParams>, +): JSX.Element { + const [isExpanded, setIsExpanded] = useState(() => { + return isInRange(props.selection, props.value); + }); + const [isSelected, setIsSelected] = useState( + () => + isInRange(props.selection, props.value) && props.value.type !== 'Program', + ); + const listItem = useRef(null); + + const onMouseEnter = useCallback( + (e: SyntheticEvent) => { + if (isEsNode(props.value)) { + props.onSelectNode(props.value as TSESTree.Node); + e.stopPropagation(); + e.preventDefault(); + } + }, + [props.value], + ); + + const onMouseLeave = useCallback( + (_e: SyntheticEvent) => { + if (isEsNode(props.value)) { + props.onSelectNode(null); + } + }, + [props.value], + ); + + useEffect(() => { + const selected = isInRange(props.selection, props.value); + + setIsSelected( + selected && + props.value.type !== 'Program' && + !hasChildInRange(props.selection, props.value), + ); + + if (selected && !isExpanded) { + setIsExpanded(isInRange(props.selection, props.value)); + } + }, [props.selection, props.value]); + + useEffect(() => { + if (listItem.current && isSelected) { + scrollIntoViewIfNeeded(listItem.current); + } + }, [isSelected, listItem]); + + return ( +
+ setIsExpanded(!isExpanded)} + /> + {'{'} + {isExpanded ? ( +
+ {filterRecord(props.value).map((item, index) => ( + + ))} +
+ ) : ( + + {filterRecord(props.value) + .map(item => item[0]) + .join(', ')} + + )} + {'}'} +
+ ); +} + +export function ElementItem(props: GenericParams): JSX.Element { + if (Array.isArray(props.value)) { + return ( + + ); + } else if ( + typeof props.value === 'object' && + props.value && + props.value.constructor === Object + ) { + return ( + } + selection={props.selection} + onSelectNode={props.onSelectNode} + /> + ); + } + return ( +
+ {props.name && {props.name}} + {props.name && : } + +
+ ); +} diff --git a/packages/website/src/components/ast/PropertyName.tsx b/packages/website/src/components/ast/PropertyName.tsx new file mode 100644 index 00000000000..f8768a6c4a2 --- /dev/null +++ b/packages/website/src/components/ast/PropertyName.tsx @@ -0,0 +1,27 @@ +import React, { SyntheticEvent } from 'react'; +import clsx from 'clsx'; +import styles from './ASTViewer.module.css'; + +export default function PropertyName(props: { + name?: string; + propName?: string; + onClick?: (e: SyntheticEvent) => void; + onMouseEnter?: (e: SyntheticEvent) => void; +}): JSX.Element { + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions + + {props.propName && ( + + {props.propName} + + )} + {props.propName && : } + {props.name && ( + + {props.name} + + )} + + ); +} diff --git a/packages/website/src/components/ast/PropertyValue.tsx b/packages/website/src/components/ast/PropertyValue.tsx new file mode 100644 index 00000000000..48f3ba119a6 --- /dev/null +++ b/packages/website/src/components/ast/PropertyValue.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import styles from './ASTViewer.module.css'; + +export default function PropertyValue(props: { value: unknown }): JSX.Element { + if (typeof props.value === 'string') { + return ( + {JSON.stringify(props.value)} + ); + } else if (typeof props.value === 'number') { + return {props.value}; + } else if (typeof props.value === 'bigint') { + return {String(props.value)}n; + } else if (props.value instanceof RegExp) { + return {String(props.value)}; + } else if (typeof props.value === 'undefined' || props.value === null) { + return {String(props.value)}; + } else if (typeof props.value === 'boolean') { + return ( + + {props.value ? 'true' : 'false'} + + ); + } + return {String(props.value)}; +} diff --git a/packages/website/src/components/ast/selection.ts b/packages/website/src/components/ast/selection.ts new file mode 100644 index 00000000000..9a882d2bf9a --- /dev/null +++ b/packages/website/src/components/ast/selection.ts @@ -0,0 +1,79 @@ +import type { TSESTree } from '@typescript-eslint/website-eslint'; +import type { Position } from './types'; + +export const propsToFilter = ['parent', 'comments', 'tokens', 'loc']; + +export function filterRecord( + values: TSESTree.Node | Record, +): [string, unknown][] { + return Object.entries(values).filter( + item => !propsToFilter.includes(item[0]), + ); +} + +export function isNode(node: unknown): node is TSESTree.Node { + return Boolean( + typeof node === 'object' && node && 'type' in node && 'loc' in node, + ); +} + +export function isWithinNode( + loc: Position, + start: Position, + end: Position, +): boolean { + const canStart = + start.line < loc.line || + (start.line === loc.line && start.column <= loc.column); + const canEnd = + end.line > loc.line || (end.line === loc.line && end.column >= loc.column); + return canStart && canEnd; +} + +export function isRecord(value: unknown): value is Record { + return Boolean( + typeof value === 'object' && value && value.constructor === Object, + ); +} + +export function isEsNode( + value: unknown, +): value is Record & TSESTree.BaseNode { + return isRecord(value) && 'type' in value && 'loc' in value; +} + +export function isInRange( + position: Position | null | undefined, + value: unknown, +): boolean { + return Boolean( + position && + isEsNode(value) && + isWithinNode(position, value.loc.start, value.loc.end), + ); +} + +export function isArrayInRange( + position: Position | null | undefined, + value: unknown, +): boolean { + return Boolean( + position && + Array.isArray(value) && + value.some(item => isInRange(position, item)), + ); +} + +export function hasChildInRange( + position: Position | null | undefined, + value: unknown, +): boolean { + return Boolean( + position && + isEsNode(value) && + filterRecord(value).some( + ([, item]) => + isInRange(position, item) || isArrayInRange(position, item), + ), + ); +} diff --git a/packages/website/src/components/ast/types.ts b/packages/website/src/components/ast/types.ts new file mode 100644 index 00000000000..53a565599ab --- /dev/null +++ b/packages/website/src/components/ast/types.ts @@ -0,0 +1,15 @@ +import type { TSESTree } from '@typescript-eslint/website-eslint'; + +export interface Position { + line: number; + column: number; +} + +export interface GenericParams { + readonly propName?: string; + readonly name?: string; + readonly value: V; + readonly level: string; + readonly selection?: Position | null; + readonly onSelectNode: (node: TSESTree.Node | null) => void; +} diff --git a/packages/website/src/components/config/ConfigEditor.module.css b/packages/website/src/components/config/ConfigEditor.module.css new file mode 100644 index 00000000000..2ca4bb4c1cc --- /dev/null +++ b/packages/website/src/components/config/ConfigEditor.module.css @@ -0,0 +1,82 @@ +.search { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background: var(--ifm-navbar-search-input-background-color) + var(--ifm-navbar-search-input-icon) no-repeat 0.75rem center / 1rem 1rem; + border: none; + color: var(--ifm-navbar-search-input-color); + cursor: text; + display: inline-block; + height: 2rem; + padding: 0 0.5rem 0 2.25rem; + font-size: 0.9rem; + flex: 1; + border-radius: 0.2rem; +} + +.searchResult, .searchResultGroup { + align-items: center; + display: flex; + flex: 0 0 1.5rem; + flex-direction: row; + font-size: 0.75rem; + padding: 0.4rem 0.8rem; + color: var(--ifm-font-color-secondary); + justify-content: space-between; +} + +.searchResult { + margin: 0 0; + transition: background-color var(--ifm-transition-fast) + var(--ifm-transition-timing-default), + color var(--ifm-transition-fast) var(--ifm-transition-timing-default); +} + +.searchResultGroup { + font-size: 1.3rem; + margin: 0.2rem 0; +} + +.searchResult:nth-child(even), .searchResultGroup:nth-child(even) { + background: var(--ifm-color-emphasis-100); +} + +.searchResult:nth-child(even) { + background: var(--ifm-color-emphasis-100); +} + +.searchResult:hover { + background: var(--ifm-color-emphasis-200); +} + +.searchResultContainer { + overflow: auto; + height: 50vh; +} + +.searchResultName { + font-size: 1.2em; +} + +.editJson { + height: 2rem; +} + +.searchBar { + display: flex; + column-gap: 0.5rem; + margin-bottom: 0.5rem; + justify-content: flex-end; +} + +.textarea { + background: var(--ifm-navbar-search-input-background-color); + border: none; + color: var(--ifm-navbar-search-input-color); + cursor: text; + width: 100%; + max-width: 100%; + min-width: 100%; + padding: 1rem; +} diff --git a/packages/website/src/components/config/ConfigEditor.tsx b/packages/website/src/components/config/ConfigEditor.tsx new file mode 100644 index 00000000000..0880ff7e6e7 --- /dev/null +++ b/packages/website/src/components/config/ConfigEditor.tsx @@ -0,0 +1,235 @@ +import React, { useCallback, useEffect, useReducer, useState } from 'react'; +import clsx from 'clsx'; + +import styles from './ConfigEditor.module.css'; + +import Text from '../inputs/Text'; +import Checkbox from '../inputs/Checkbox'; +import useFocus from '../hooks/useFocus'; +import Modal from '@site/src/components/modals/Modal'; + +export interface ConfigOptionsField { + key: string; + label?: string; + defaults?: unknown[]; +} + +export interface ConfigOptionsType { + heading: string; + fields: ConfigOptionsField[]; +} + +export type ConfigEditorValues = Record; + +export interface ConfigEditorProps { + readonly options: ConfigOptionsType[]; + readonly values: ConfigEditorValues; + readonly isOpen: boolean; + readonly header: string; + readonly jsonField: string; + readonly onClose: (config: ConfigEditorValues) => void; +} + +function reducerJson( + _state: string, + action: string | { field: string; value: ConfigEditorValues }, +): string { + if (typeof action === 'string') { + return action; + } else if (action && typeof action === 'object') { + return JSON.stringify( + { + [action.field]: action.value, + }, + null, + 2, + ); + } + throw new Error(); +} + +function isRecord(data: unknown): data is Record { + return Boolean(data && typeof data === 'object'); +} + +function reducerObject( + state: ConfigEditorValues, + action: + | { type: 'init'; config?: ConfigEditorValues } + | { + type: 'toggle'; + checked: boolean; + default: unknown[] | undefined; + name: string; + } + | { type: 'json'; field: string; code: string }, +): ConfigEditorValues { + switch (action.type) { + case 'init': { + return action.config ?? {}; + } + case 'toggle': { + const newState = { ...state }; + if (action.checked) { + newState[action.name] = action.default ? action.default[0] : true; + } else if (action.name in newState) { + delete newState[action.name]; + } + return newState; + } + case 'json': { + try { + const parsed: unknown = JSON.parse(action.code); + if (isRecord(parsed)) { + const item = parsed[action.field]; + if (item && isRecord(item)) { + return item; + } + } + } catch { + // eslint-disable-next-line no-console + console.error('ERROR parsing json'); + } + return state; + } + } + // @ts-expect-error: Safeguard + throw new Error(); +} + +function filterConfig( + options: ConfigOptionsType[], + filter: string, +): ConfigOptionsType[] { + return options + .map(group => ({ + heading: group.heading, + fields: group.fields.filter(item => String(item.key).includes(filter)), + })) + .filter(group => group.fields.length > 0); +} + +function isDefault(value: unknown, defaults?: unknown[]): boolean { + return defaults ? defaults.includes(value) : value === true; +} + +function ConfigEditor(props: ConfigEditorProps): JSX.Element { + const [filter, setFilter] = useState(''); + const [editJson, setEditJson] = useState(false); + const [config, setConfig] = useReducer(reducerObject, {}); + const [jsonCode, setJsonCode] = useReducer(reducerJson, ''); + const [filterInput, setFilterFocus] = useFocus(); + const [jsonInput, setJsonFocus] = useFocus(); + + const onClose = useCallback(() => { + if (editJson) { + props.onClose( + reducerObject(config, { + type: 'json', + field: props.jsonField, + code: jsonCode, + }), + ); + } else { + props.onClose(config); + } + }, [props.onClose, props.jsonField, jsonCode, config]); + + useEffect(() => { + setConfig({ type: 'init', config: props.values }); + }, [props.values]); + + useEffect(() => { + if (props.isOpen) { + if (!editJson) { + setFilterFocus(); + } else { + setJsonFocus(); + } + } + }, [editJson, props.isOpen]); + + const changeEditType = useCallback(() => { + if (editJson) { + setConfig({ type: 'json', field: props.jsonField, code: jsonCode }); + } else { + setJsonCode({ field: props.jsonField, value: config }); + } + setEditJson(!editJson); + }, [editJson, config, jsonCode, props.jsonField]); + + return ( + +
+ {!editJson && ( + + )} + +
+ {editJson && ( +