From 28466c4e6935c09cee166a1a29481ebd459d96cd Mon Sep 17 00:00:00 2001 From: qwqcode Date: Sat, 5 Oct 2024 17:13:35 +0800 Subject: [PATCH] feat(ui/eslint): add eslint plugin for artalk ui development - Implemented `eslint-plugin-artalk` to enforce Artalk's development conventions. - Added setup script for UI development environment. - Created test cases for Artalk plugin rules. - Configured TypeScript and build settings for the plugin. - Updated documentation with installation and configuration instructions. --- scripts/setup-ui-dev-env.mjs | 29 ++ ui/eslint-plugin-artalk/README.md | 121 ++++++++ ui/eslint-plugin-artalk/package.json | 38 +++ .../src/artalk-plugin.test.ts | 42 +++ ui/eslint-plugin-artalk/src/artalk-plugin.ts | 276 ++++++++++++++++++ ui/eslint-plugin-artalk/src/helper.ts | 6 + ui/eslint-plugin-artalk/src/main.ts | 12 + ui/eslint-plugin-artalk/src/test-helper.ts | 25 ++ ui/eslint-plugin-artalk/tsconfig.json | 7 + ui/eslint-plugin-artalk/tsup.config.ts | 21 ++ 10 files changed, 577 insertions(+) create mode 100644 scripts/setup-ui-dev-env.mjs create mode 100644 ui/eslint-plugin-artalk/README.md create mode 100644 ui/eslint-plugin-artalk/package.json create mode 100644 ui/eslint-plugin-artalk/src/artalk-plugin.test.ts create mode 100644 ui/eslint-plugin-artalk/src/artalk-plugin.ts create mode 100644 ui/eslint-plugin-artalk/src/helper.ts create mode 100644 ui/eslint-plugin-artalk/src/main.ts create mode 100644 ui/eslint-plugin-artalk/src/test-helper.ts create mode 100644 ui/eslint-plugin-artalk/tsconfig.json create mode 100644 ui/eslint-plugin-artalk/tsup.config.ts diff --git a/scripts/setup-ui-dev-env.mjs b/scripts/setup-ui-dev-env.mjs new file mode 100644 index 000000000..94d424acb --- /dev/null +++ b/scripts/setup-ui-dev-env.mjs @@ -0,0 +1,29 @@ +import { exec } from 'child_process' + +function runCommand(command) { + return new Promise((resolve, reject) => { + exec(command, (error, stdout, stderr) => { + if (error) { + reject(stderr || stdout) + } else { + resolve(stdout) + } + }) + }) +} + +async function build() { + try { + // Build Artalk Plugin Kit for plugin development + await runCommand('pnpm build:plugin-kit') + const green = '\x1b[32m' + console.log(green, '[ArtalkDev] build @artalk/plugin-kit success') + // Build Artalk eslint plugin for lint checking + await runCommand('pnpm build:eslint-plugin') + console.log(green, '[ArtalkDev] build eslint-plugin-artalk success') + } catch (error) { + console.error('[ArtalkDev] Artalk UI development environment setup failed:', error) + } +} + +build() diff --git a/ui/eslint-plugin-artalk/README.md b/ui/eslint-plugin-artalk/README.md new file mode 100644 index 000000000..5dabf9373 --- /dev/null +++ b/ui/eslint-plugin-artalk/README.md @@ -0,0 +1,121 @@ +# eslint-plugin-artalk [![npm](https://img.shields.io/npm/v/eslint-plugin-artalk)](https://www.npmjs.com/package/eslint-plugin-artalk) + +The ESLint plugin enforcing Artalk's development conventions. + +It is a part of the [Plugin Development Kit](https://artalk.js.org/develop/plugin.html) for Artalk. + +## Installation + +```bash +pnpm add -D eslint-plugin-artalk +``` + +Since Artalk development is based on TypeScript and the plugin relies on it, you need to install `typescript` and `@typescript-eslint/parser`. For more details, refer to [TypeScript ESLint](https://typescript-eslint.io/getting-started/). + +### Flat Configuration + +Modify the `eslint.config.mjs` file in your project: + + +```js +import eslintJs from '@eslint/js' +import eslintTs from 'typescript-eslint' +import pluginArtalk from 'eslint-plugin-artalk' + +export default eslintTs.config( + eslintJs.configs.recommended, + ...eslintTs.configs.recommended, + { + files: ['**/*.{ts,mts,cts,tsx,js,mjs,cjs}'], + languageOptions: { + parser: eslintTs.parser, + parserOptions: { + project: './tsconfig.json', + tsconfigRootDir: __dirname, + sourceType: 'module', + }, + }, + plugins: { + artalk: pluginArtalk, + }, + rules: { + ...pluginArtalk.configs.recommended.rules, + }, + } +) +``` + + + +### Custom Configuration + +You can customize the rules by modifying the `rules` field in the configuration: + +```js +{ + plugins: { + artalk: pluginArtalk, + }, + rules: { + 'artalk/artalk-plugin': 'error', + }, +} +``` + +## Valid and Invalid Examples + +### Rule `artalk-plugin` + +The ESLint rule `artalk/artalk-plugin` enforces the conventions for Artalk plugins. + +The ESLint rule is only enabled when a TypeScript file imports the `ArtalkPlugin` type from the `artalk` package and defines an arrow function variable with the type `ArtalkPlugin`, such as `const TestPlugin: ArtalkPlugin = (ctx) => {}`. The variable type must be `ArtalkPlugin`. + +#### `noLifeCycleEventInNestedBlocks` + +Should not allow life-cycle event listeners to be defined inside nested blocks. + +The life-cycle event listeners are `created`, `mounted`, `updated`, and `destroyed` must be defined in the top-level scope of the ArtalkPlugin arrow function. + +**⚠️ Fail**: + +```ts +import type { ArtalkPlugin } from 'artalk' + +export const TestPlugin: ArtalkPlugin = (ctx) => { + const foo = () => { + const bar = () => { + ctx.on('updated', () => {}) + } + } +} +``` + +**✅ Pass**: + +```ts +import type { ArtalkPlugin } from 'artalk' + +export const TestPlugin: ArtalkPlugin = (ctx) => { + ctx.on('updated', () => {}) +} +``` + +#### `noEventInWatchConf` + +Should not allow event listeners to be defined inside watchConf effect function. + +**⚠️ Fail**: + +```ts +import type { ArtalkPlugin } from 'artalk' + +export const TestPlugin: ArtalkPlugin = (ctx) => { + ctx.watchConf(['el'], (conf) => { + ctx.on('update', () => {}) + }) +} +``` + +## License + +[MIT](https://github.com/ArtalkJS/Artalk/blob/master/LICENSE) diff --git a/ui/eslint-plugin-artalk/package.json b/ui/eslint-plugin-artalk/package.json new file mode 100644 index 000000000..e76d6571b --- /dev/null +++ b/ui/eslint-plugin-artalk/package.json @@ -0,0 +1,38 @@ +{ + "name": "eslint-plugin-artalk", + "version": "1.0.0", + "type": "module", + "license": "MIT", + "homepage": "https://github.com/ArtalkJS/Artalk/tree/master/ui/eslint-plugin-artalk", + "files": [ + "dist", + "README.md" + ], + "main": "dist/main.js", + "types": "dist/main.d.ts", + "exports": { + ".": { + "require": { + "types": "./dist/main.d.cjs", + "default": "./dist/main.cjs" + }, + "default": { + "types": "./dist/main.d.ts", + "default": "./dist/main.js" + } + } + }, + "scripts": { + "dev": "tsup --watch", + "build": "tsup", + "test": "vitest" + }, + "devDependencies": { + "@typescript-eslint/rule-tester": "^8.8.0", + "tsup": "^8.3.0" + }, + "peerDependencies": { + "@typescript-eslint/utils": ">=8", + "eslint": ">=9" + } +} diff --git a/ui/eslint-plugin-artalk/src/artalk-plugin.test.ts b/ui/eslint-plugin-artalk/src/artalk-plugin.test.ts new file mode 100644 index 000000000..2ddde59f8 --- /dev/null +++ b/ui/eslint-plugin-artalk/src/artalk-plugin.test.ts @@ -0,0 +1,42 @@ +import { artalkPlugin } from './artalk-plugin' +import { setupTest } from './test-helper' + +const { ruleTester } = setupTest() + +const invalid = [ + { + name: 'should not allow life-cycle event functions in nested blocks', + code: ` + import type { ArtalkPlugin } from 'artalk' + + export const TestPlugin: ArtalkPlugin = (ctx) => { + const foo = () => { + const bar = () => { + ctx.on('updated', () => {}) + } + } + } + `, + errorId: 'noLifeCycleEventInNestedBlocks', + }, + { + name: "should not allow 'off' call inside watchConf effect", + code: ` + import type { ArtalkPlugin } from 'artalk' + + export const TestPlugin: ArtalkPlugin = (ctx) => { + ctx.watchConf(['pageVote'], (conf) => { + ctx.off('updated', () => {}) + }) + } + `, + errorId: 'noEventInWatchConf', + }, +] + +for (const { name, code, errorId } of invalid) { + ruleTester.run(name, artalkPlugin as any, { + valid: [], + invalid: [{ code, errors: [{ messageId: errorId }] }], + }) +} diff --git a/ui/eslint-plugin-artalk/src/artalk-plugin.ts b/ui/eslint-plugin-artalk/src/artalk-plugin.ts new file mode 100644 index 000000000..5e8094203 --- /dev/null +++ b/ui/eslint-plugin-artalk/src/artalk-plugin.ts @@ -0,0 +1,276 @@ +import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils' +import type { ContextApi } from '../../artalk/src/types/context' +import { createRule } from './helper' +type _ = ContextApi // for IDE jump-to-definition + +/** Whether the given string is a ArtalkPlugin name */ +function isPluginName(s: string) { + return s === 'ArtalkPlugin' || /Artalk[A-Z0-9].*Plugin/.test(s) +} + +/** The event function names in ContextApi */ +const ctxEventFns = ['off', 'on', 'trigger'] + +/** The life-cycle event names in ContextApi */ +const ctxLifeCycleEvents = ['mounted', 'destroyed', 'updated', 'list-fetched'] + +export const artalkPlugin = createRule({ + name: 'artalk-plugin', + meta: { + type: 'problem', + docs: { + description: + 'Enforce best practices for ArtalkPlugin arrow functions, including ContextApi usage.', + }, + messages: { + noLifeCycleEventInNestedBlocks: + 'The life-cycle event `{{ eventName }}` listeners should only be defined in the top-level scope of the ArtalkPlugin.', + noEventInWatchConf: 'Avoid calling `{{ functionName }}` inside the `ctx.watchConf` effect.', + }, + schema: [], + }, + defaultOptions: [], + create(context) { + // Initialize the TypeScript parser services + const parserServices = context.sourceCode.parserServices + if (!parserServices || !parserServices.program) { + console.error('[eslint-plugin-artalk] Missing typescript parser services.') + return {} + } + const checker = parserServices.program.getTypeChecker() + + // ------------------------------------------------------------------- + // Utility functions + // ------------------------------------------------------------------- + const getTypeName = (node: TSESTree.Node) => { + const tsNode = parserServices?.esTreeNodeToTSNodeMap?.get(node) + const tsType = tsNode ? checker.getTypeAtLocation(tsNode) : null + const typeName = tsType ? checker.typeToString(tsType) : '' + return typeName + } + + const getArrowFunctionType = (node: TSESTree.Node) => { + if (node.type === 'ArrowFunctionExpression') return getTypeName(node) + return '' + } + + const isInsideArtalkPlugin = (node: TSESTree.Node) => { + let curr: TSESTree.Node | undefined = node + while (curr) { + if (isPluginName(getArrowFunctionType(curr))) return true + curr = curr.parent + } + return false + } + + /** + * Get the references to ContextApi in the top scope of the given scope + */ + const getCtxRefNamesInTopScope = (ctxArgName: string, scope: TSESLint.Scope.Scope) => { + const ctxRefs = new Map() + + const getFullMethodName = (node: TSESTree.Node) => { + const methodNameArr: string[] = [] + let curr: TSESTree.Node | undefined = node + while (curr) { + if (curr.type === 'MemberExpression' && curr.property.type === 'Identifier') + methodNameArr.push(curr.property.name) + curr = curr.parent + } + return methodNameArr.join('.') + } + + scope.references.forEach((reference) => { + const identifier = reference.identifier + if (identifier.name !== ctxArgName) return + + const methodName = getFullMethodName(identifier.parent) + if (methodName) ctxRefs.set(identifier.parent, methodName) + }) + + return ctxRefs + } + + /** + * Get the references to ContextApi in the nested scopes of the given + */ + const getCtxRefNamesInNestedScope = ( + ctxArgName: string, + parentScope: TSESLint.Scope.Scope, + keepTop = true, + ) => { + const ctxRefs = new Map() + keepTop && + getCtxRefNamesInTopScope(ctxArgName, parentScope).forEach((v, k) => ctxRefs.set(k, v)) + parentScope.childScopes.forEach((childScope) => { + getCtxRefNamesInNestedScope(ctxArgName, childScope).forEach((v, k) => ctxRefs.set(k, v)) + }) + return ctxRefs + } + + // ------------------------------------------------------------------- + // Checker functions + // ------------------------------------------------------------------- + + /** + * Check the set of all function names in ContextApi + * + * (which is called in the top-level of ArtalkPlugin arrow-function scope) + */ + const checkTopLevelCtxRefs = (m: Map) => { + // console.debug('checkTopLevelCtxFnCalls', m.values()) + // ... + } + + /** + * Check the set of all function names in ContextApi + * + * (which is called in the nested scopes of ArtalkPlugin arrow-function scope) + */ + const checkNestedCtxRefs = (m: Map) => { + // console.debug('checkAllCtxFnCalls', m.values()) + // ... + // TODO: Event Circular trigger Check + } + + /** + * Check the set of all function names in ContextApi + * + * (which is called in the nested scopes of ArtalkPlugin arrow-function scope, excluding the top-level) + */ + const checkNestedCtxRefsNoTop = (m: Map) => { + m.forEach((methodName, node) => { + // Disallow life-cycle events in nested blocks + if (methodName === 'on') { + // Get the call arguments + const parent = node.parent + if (!parent || parent.type !== 'CallExpression') return + if (parent.arguments.length == 0) return + const eventNameArg = parent.arguments[0] + if (eventNameArg.type !== 'Literal') return + const eventName = eventNameArg.value + if (typeof eventName !== 'string') return + if (ctxLifeCycleEvents.includes(eventName)) { + context.report({ + node: parent, + messageId: 'noLifeCycleEventInNestedBlocks', + data: { + eventName, + }, + }) + } + } + }) + } + + /** + * Check the set of all function names in ContextApi + * + * (which is called in the watchConf effect function scope) + */ + const checkWatchConfCalls = (m: Map) => { + const disallowedMethods = [...ctxEventFns] + m.forEach((methodName, node) => { + if (disallowedMethods.includes(methodName)) { + context.report({ + node: node.parent || node, + messageId: 'noEventInWatchConf', + data: { functionName: `ctx.${methodName}` }, + }) + } + }) + } + + /** + * Whether the ArtalkPlugin is imported + * + * (to enable the plugin checker) + */ + let pluginCheckerEnabled = false + + return { + ImportDeclaration(node) { + // Check if contains ArtalkPlugin importing + node.specifiers.forEach((specifier) => { + if (specifier.type !== 'ImportSpecifier') return + if (isPluginName(specifier.imported.name)) { + pluginCheckerEnabled = true + } + }) + }, + + VariableDeclaration(fnNode) { + if (!pluginCheckerEnabled) return + + // Check if the variable declaration is ArtalkPlugin + fnNode.declarations.forEach((decl) => { + if ( + isPluginName(getTypeName(decl)) && + decl.init && + decl.init?.type == 'ArrowFunctionExpression' + ) { + // Is ArtalkPlugin arrow-function + const pluginFn = decl.init + + // Get the first parameter name as the ContextApi reference + if (pluginFn.params.length === 0) return // No ctx reference + const ctxArg = pluginFn.params[0] + if (ctxArg.type !== 'Identifier') return + const ctxArgName = ctxArg.name + + // Visit the top-level scope of the ArtalkPlugin arrow-function + const pluginFnScope = context.sourceCode.getScope(pluginFn.body) + const topLevelCtxRefs = getCtxRefNamesInTopScope(ctxArgName, pluginFnScope) + checkTopLevelCtxRefs(topLevelCtxRefs) + + // Visit all nested scopes (including the top-level) of the ArtalkPlugin arrow-function + const nestedCtxRefsIncludeTop = getCtxRefNamesInNestedScope( + ctxArgName, + pluginFnScope, + true, + ) + checkNestedCtxRefs(nestedCtxRefsIncludeTop) + + // Visit all nested scopes (excluding the top-level) of the ArtalkPlugin arrow-function + const nestedCtxRefsExcludeTop = getCtxRefNamesInNestedScope( + ctxArgName, + pluginFnScope, + false, + ) + checkNestedCtxRefsNoTop(nestedCtxRefsExcludeTop) + + // Visit watchConf effect function scope + const watchConfCalls = new Map() + topLevelCtxRefs.forEach((v, k) => { + if (v === 'watchConf') { + // Get the watchConf call expression + const watchConfCall = k.parent + if (!watchConfCall || watchConfCall.type !== AST_NODE_TYPES.CallExpression) return + + // Get the watchConf effect function + if (watchConfCall.arguments.length < 2) return + const watchConfEffectFn = watchConfCall.arguments[1] + if ( + watchConfEffectFn.type !== 'ArrowFunctionExpression' && + watchConfEffectFn.type !== 'FunctionExpression' + ) + return + + // Get the references to ContextApi in the watchConf effect function top scope + const scope = context.sourceCode.getScope(watchConfEffectFn.body) + getCtxRefNamesInTopScope(ctxArgName, scope).forEach((v, k) => + watchConfCalls.set(k, v), + ) + } + }) + checkWatchConfCalls(watchConfCalls) + } + }) + }, + + Identifier(node) {}, + + CallExpression(node) {}, + } + }, +}) diff --git a/ui/eslint-plugin-artalk/src/helper.ts b/ui/eslint-plugin-artalk/src/helper.ts new file mode 100644 index 000000000..3ffdcd402 --- /dev/null +++ b/ui/eslint-plugin-artalk/src/helper.ts @@ -0,0 +1,6 @@ +import { ESLintUtils } from '@typescript-eslint/utils' + +export const createRule = ESLintUtils.RuleCreator( + (name) => + `https://github.com/ArtalkJS/Artalk/tree/master/ui/eslint-plugin-artalk/README.md#rule-${name}`, +) diff --git a/ui/eslint-plugin-artalk/src/main.ts b/ui/eslint-plugin-artalk/src/main.ts new file mode 100644 index 000000000..4a119df59 --- /dev/null +++ b/ui/eslint-plugin-artalk/src/main.ts @@ -0,0 +1,12 @@ +import { artalkPlugin } from './artalk-plugin' + +export const rules = { + 'artalk-plugin': artalkPlugin, +} + +export default { + rules, + configs: { + recommended: { plugins: ['artalk'], rules: { 'artalk/artalk-plugin': 'warn' } }, + }, +} diff --git a/ui/eslint-plugin-artalk/src/test-helper.ts b/ui/eslint-plugin-artalk/src/test-helper.ts new file mode 100644 index 000000000..99f41c352 --- /dev/null +++ b/ui/eslint-plugin-artalk/src/test-helper.ts @@ -0,0 +1,25 @@ +import * as vitest from 'vitest' +import { RuleTester } from '@typescript-eslint/rule-tester' + +let ruleTester: RuleTester | undefined + +export function setupTest() { + if (ruleTester) return { ruleTester } + + RuleTester.afterAll = vitest.afterAll + RuleTester.it = vitest.it + RuleTester.itOnly = vitest.it.only + RuleTester.describe = vitest.describe + + ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + projectService: { + allowDefaultProject: ['*.ts*'], + }, + }, + }, + }) + + return { ruleTester } +} diff --git a/ui/eslint-plugin-artalk/tsconfig.json b/ui/eslint-plugin-artalk/tsconfig.json new file mode 100644 index 000000000..122e9c5f0 --- /dev/null +++ b/ui/eslint-plugin-artalk/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "dist" + } +} diff --git a/ui/eslint-plugin-artalk/tsup.config.ts b/ui/eslint-plugin-artalk/tsup.config.ts new file mode 100644 index 000000000..613ab5571 --- /dev/null +++ b/ui/eslint-plugin-artalk/tsup.config.ts @@ -0,0 +1,21 @@ +import { defineConfig, Options } from 'tsup' + +const shared: Options = { + format: ['esm', 'cjs'], + target: 'node14', + platform: 'node', + shims: true, + splitting: false, + bundle: true, + sourcemap: true, + clean: false, + dts: true, +} + +export default defineConfig([ + { + ...shared, + outDir: 'dist', + entry: ['src/main.ts'], + }, +])