feat: add js linter to code editor

This commit is contained in:
gchust 2025-06-23 21:03:09 +08:00
parent de270ff258
commit 5006644732
5 changed files with 423 additions and 5 deletions

View File

@ -10,6 +10,7 @@ A simplified NocoBase plugin that enables creating lowcode blocks using custom J
- **DOM Manipulation**: Direct access to the component's DOM element for rendering
- **Flow Page Integration**: Automatically appears in the "Add Block" dropdown in flow pages
- **CodeMirror Editor**: Advanced code editor with syntax highlighting, smart auto-completion, and JavaScript language support
- **Real-time Syntax Checking**: Instant error detection and helpful warnings for common programming issues
- **Context-Aware Autocomplete**: Intelligent suggestions for available variables (element, ctx, model, requirejs, requireAsync, loadCSS)
- **Code Snippets**: Pre-built templates for common operations like loading libraries, creating elements, and async operations
- **Simple Configuration**: Single-step configuration with execution code
@ -20,6 +21,23 @@ Each lowcode component block has one main configuration:
1. **Execution Code**: Custom JavaScript code that will be executed to render the component
## Code Editor Features
The built-in CodeMirror editor provides enhanced development experience:
### ✅ Real-time Syntax Checking
- **Syntax Errors**: Instant detection of JavaScript syntax errors using Acorn parser
- **Undefined Variables**: Precise detection of undefined variable usage
- **Scope Analysis**: Intelligent variable scope tracking across functions and blocks
- **Context Awareness**: Recognizes lowcode environment variables (element, ctx, model, etc.)
### 🎯 Visual Error Indicators
- **Error Underlines**: Red wavy underlines for syntax errors
- **Warning Underlines**: Yellow wavy underlines for potential issues
- **Info Underlines**: Blue wavy underlines for optimization suggestions
- **Gutter Icons**: Error/warning indicators in the editor gutter
- **Hover Tooltips**: Detailed error descriptions on hover
## Example Usage
### 1. ECharts Data Visualization

View File

@ -20,7 +20,11 @@
"react": "^18.2.0",
"react-i18next": "^11.15.1",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/view": "^6.37.2"
"@codemirror/view": "^6.37.2",
"@codemirror/lint": "^6.8.3",
"@codemirror/language": "^6.11.3",
"acorn": "^8.11.3",
"acorn-walk": "^8.3.2"
},
"peerDependencies": {
"@nocobase/client": "workspace:*",

View File

@ -16,6 +16,8 @@ import { javascript } from '@codemirror/lang-javascript';
import { oneDark } from '@codemirror/theme-one-dark';
import { EditorState } from '@codemirror/state';
import { autocompletion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import { lintGutter } from '@codemirror/lint';
import { createJavaScriptLinter } from './linter';
// 自定义自动补全函数
const createCustomCompletion = () => {
@ -225,6 +227,7 @@ interface CodeEditorProps {
height?: string | number;
theme?: 'light' | 'dark';
readonly?: boolean;
enableLinter?: boolean;
}
const CodeEditorComponent: React.FC<CodeEditorProps> = ({
@ -234,6 +237,7 @@ const CodeEditorComponent: React.FC<CodeEditorProps> = ({
height = '300px',
theme = 'light',
readonly = false,
enableLinter = false,
}) => {
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
@ -249,6 +253,8 @@ const CodeEditorComponent: React.FC<CodeEditorProps> = ({
closeOnBlur: false,
activateOnTyping: true,
}),
// 条件性添加语法检查和错误提示
...(enableLinter ? [lintGutter(), createJavaScriptLinter()] : []),
EditorView.updateListener.of((update) => {
if (update.docChanged && onChange && !readonly) {
const newValue = update.state.doc.toString();
@ -270,6 +276,46 @@ const CodeEditorComponent: React.FC<CodeEditorProps> = ({
borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
},
// 语法错误提示样式
'.cm-diagnostic': {
padding: '4px 8px',
borderRadius: '4px',
border: '1px solid #d9d9d9',
backgroundColor: '#fff',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
fontSize: '12px',
maxWidth: '300px',
},
'.cm-diagnostic-error': {
borderLeftColor: '#ff4d4f',
borderLeftWidth: '3px',
},
'.cm-diagnostic-warning': {
borderLeftColor: '#faad14',
borderLeftWidth: '3px',
},
'.cm-diagnostic-info': {
borderLeftColor: '#1890ff',
borderLeftWidth: '3px',
},
'.cm-lintRange-error': {
backgroundImage:
'url("data:image/svg+xml;charset=utf8,<svg xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"6\\" height=\\"3\\"><path d=\\"m0 3 l2 -2 l1 0 l2 2 l1 0\\" stroke=\\"%23ff4d4f\\" fill=\\"none\\" stroke-width=\\".7\\"/></svg>")',
backgroundRepeat: 'repeat-x',
backgroundPosition: 'left bottom',
},
'.cm-lintRange-warning': {
backgroundImage:
'url("data:image/svg+xml;charset=utf8,<svg xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"6\\" height=\\"3\\"><path d=\\"m0 3 l2 -2 l1 0 l2 2 l1 0\\" stroke=\\"%23faad14\\" fill=\\"none\\" stroke-width=\\".7\\"/></svg>")',
backgroundRepeat: 'repeat-x',
backgroundPosition: 'left bottom',
},
'.cm-lintRange-info': {
backgroundImage:
'url("data:image/svg+xml;charset=utf8,<svg xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"6\\" height=\\"3\\"><path d=\\"m0 3 l2 -2 l1 0 l2 2 l1 0\\" stroke=\\"%231890ff\\" fill=\\"none\\" stroke-width=\\".7\\"/></svg>")',
backgroundRepeat: 'repeat-x',
backgroundPosition: 'left bottom',
},
}),
];
@ -308,7 +354,7 @@ const CodeEditorComponent: React.FC<CodeEditorProps> = ({
view.destroy();
viewRef.current = null;
};
}, [theme, height, placeholder, readonly]);
}, [theme, height, placeholder, readonly, enableLinter]);
// Update editor content when value prop changes
useEffect(() => {
@ -353,9 +399,16 @@ const CodeEditorComponent: React.FC<CodeEditorProps> = ({
// Connect with Formily
export const CodeEditor = connect(
CodeEditorComponent,
mapProps({
value: 'value',
readOnly: 'readonly',
mapProps((props, field) => {
return {
value: props.value,
readonly: props.readonly,
enableLinter: props.enableLinter,
onChange: props.onChange,
placeholder: props.placeholder,
height: props.height,
theme: props.theme,
};
}),
);

View File

@ -113,6 +113,7 @@ LowcodeBlockFlowModel.registerFlow({
'x-component-props': {
height: '400px',
theme: 'light',
enableLinter: true,
placeholder: `// Welcome to the lowcode block
// Build interactive components with JavaScript and external libraries

View File

@ -0,0 +1,342 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { linter } from '@codemirror/lint';
import * as acorn from 'acorn';
import { simple as walk } from 'acorn-walk';
interface CommentDirectives {
globalDirectives: string[];
ignoreRanges: Array<{ start: number; end: number }>;
}
/**
*
*/
const parseCommentDirectives = (code: string): CommentDirectives => {
const globalDirectives: string[] = [];
const ignoreRanges: Array<{ start: number; end: number }> = [];
// 匹配 /* global var1, var2 */ 或 // global var1, var2
const globalRegex = /(?:\/\*\s*global\s+([^*]+)\*\/|\/\/\s*global\s+(.+))/gi;
let match;
while ((match = globalRegex.exec(code)) !== null) {
const varsText = match[1] || match[2];
if (varsText) {
const vars = varsText
.split(',')
.map((v) => v.trim())
.filter((v) => v);
globalDirectives.push(...vars);
}
}
// 匹配 /* eslint-disable */ 或 // eslint-disable-line
const disableRegex = /(?:\/\*\s*eslint-disable(?:\s+[^*]+)?\s*\*\/|\/\/\s*eslint-disable-line)/gi;
while ((match = disableRegex.exec(code)) !== null) {
// 找到禁用注释的行
const lines = code.substring(0, match.index).split('\n');
const lineNumber = lines.length - 1;
const lineStart = lines.slice(0, lineNumber).join('\n').length + (lineNumber > 0 ? 1 : 0);
const lineEnd = lineStart + (lines[lineNumber]?.length || 0);
ignoreRanges.push({ start: lineStart, end: lineEnd });
}
return { globalDirectives, ignoreRanges };
};
/**
*
*/
const getDefaultGlobals = (): Set<string> => {
return new Set([
// lowcode 环境提供的变量
'element',
'ctx',
'model',
'requirejs',
'requireAsync',
'loadCSS',
// JavaScript 内置对象
'console',
'window',
'document',
'setTimeout',
'setInterval',
'clearTimeout',
'clearInterval',
'Promise',
'Array',
'Object',
'String',
'Number',
'Boolean',
'Date',
'Math',
'JSON',
'Error',
'TypeError',
'ReferenceError',
'SyntaxError',
// 常用的全局函数
'parseInt',
'parseFloat',
'isNaN',
'isFinite',
'encodeURIComponent',
'decodeURIComponent',
'fetch',
'XMLHttpRequest',
'FormData',
'URLSearchParams',
// 现代 JavaScript
'Map',
'Set',
'WeakMap',
'WeakSet',
'Symbol',
'Proxy',
'Reflect',
// 异步相关
'async',
'await',
// 常用库可能暴露的全局变量
'echarts',
'Chart',
'_',
'lodash',
'$',
'jQuery',
'moment',
]);
};
/**
*
*/
const collectDefinedVariables = (ast: any, availableGlobals: Set<string>): Set<string> => {
const definedVariables = new Set(availableGlobals);
try {
walk(ast, {
// 变量声明
VariableDeclarator(node) {
if (node && node.id && node.id.type === 'Identifier' && node.id.name) {
definedVariables.add(node.id.name);
}
},
// 函数声明
FunctionDeclaration(node) {
if (node && node.id && node.id.name) {
definedVariables.add(node.id.name);
}
// 函数参数
if (node && node.params && Array.isArray(node.params)) {
node.params.forEach((param) => {
if (param && param.type === 'Identifier' && param.name) {
definedVariables.add(param.name);
}
});
}
},
// 函数表达式参数
FunctionExpression(node) {
if (node && node.id && node.id.name) {
definedVariables.add(node.id.name);
}
if (node && node.params && Array.isArray(node.params)) {
node.params.forEach((param) => {
if (param && param.type === 'Identifier' && param.name) {
definedVariables.add(param.name);
}
});
}
},
// 箭头函数参数
ArrowFunctionExpression(node) {
if (node && node.params && Array.isArray(node.params)) {
node.params.forEach((param) => {
if (param && param.type === 'Identifier' && param.name) {
definedVariables.add(param.name);
}
});
}
},
});
} catch (e) {
console.warn('Variable collection failed:', e);
}
return definedVariables;
};
/**
* 使
*/
const checkIdentifierUsage = (
ast: any,
definedVariables: Set<string>,
shouldIgnoreError: (position: number) => boolean,
) => {
const diagnostics: any[] = [];
try {
walk(ast, {
Identifier(node, ancestors) {
// 基本验证
if (!node || !node.name || typeof node.name !== 'string') {
return;
}
// 确保有父节点信息
if (!ancestors || !Array.isArray(ancestors) || ancestors.length < 2) {
// 如果没有父节点信息,假设是变量使用并检查
if (!definedVariables.has(node.name)) {
const errorPos = node.start || 0;
if (!shouldIgnoreError(errorPos)) {
diagnostics.push({
from: errorPos,
to: node.end || errorPos + node.name.length,
severity: 'error',
message: `'${node.name}' is not defined`,
actions: [],
});
}
}
return;
}
// 检查是否在定义位置(忽略定义时的标识符)
const parent = ancestors[ancestors.length - 2];
if (!parent || !parent.type) {
// 没有父节点类型信息,假设是变量使用
if (!definedVariables.has(node.name)) {
const errorPos = node.start || 0;
if (!shouldIgnoreError(errorPos)) {
diagnostics.push({
from: errorPos,
to: node.end || errorPos + node.name.length,
severity: 'error',
message: `'${node.name}' is not defined`,
actions: [],
});
}
}
return;
}
const isDefinition =
(parent.type === 'VariableDeclarator' && parent.id === node) ||
(parent.type === 'FunctionDeclaration' && parent.id === node) ||
(parent.type === 'Property' && parent.key === node && !parent.computed) ||
(parent.type === 'MemberExpression' && parent.property === node && !parent.computed) ||
(parent.type === 'AssignmentPattern' && parent.left === node) ||
parent.type === 'ImportDefaultSpecifier' ||
parent.type === 'ImportSpecifier';
// 如果不是定义位置,且变量未定义,则报错
if (!isDefinition && !definedVariables.has(node.name)) {
const errorPos = node.start || 0;
// 检查是否应该忽略这个错误
if (!shouldIgnoreError(errorPos)) {
diagnostics.push({
from: errorPos,
to: node.end || errorPos + node.name.length,
severity: 'error',
message: `'${node.name}' is not defined`,
actions: [],
});
}
}
},
});
} catch (e) {
console.warn('Identifier checking failed:', e);
}
return diagnostics;
};
/**
* JavaScript
*/
export const createJavaScriptLinter = () => {
return linter((view) => {
const diagnostics: any[] = [];
const text = view.state.doc.toString();
// 如果代码为空,不进行检查
if (!text.trim()) {
return diagnostics;
}
// 解析注释指令
const { globalDirectives, ignoreRanges } = parseCommentDirectives(text);
// 定义可用的全局变量
const availableGlobals = getDefaultGlobals();
// 添加注释中声明的全局变量
globalDirectives.forEach((varName) => availableGlobals.add(varName));
// 检查错误是否应该被忽略
const shouldIgnoreError = (position: number): boolean => {
return ignoreRanges.some((range) => position >= range.start && position <= range.end);
};
try {
// 使用 acorn 解析代码
const ast = acorn.parse(text, {
ecmaVersion: 2022,
sourceType: 'script',
allowAwaitOutsideFunction: true,
locations: true,
});
// 检查 AST 是否有效
if (!ast || typeof ast !== 'object') {
return diagnostics;
}
// 收集所有变量声明
const definedVariables = collectDefinedVariables(ast, availableGlobals);
// 检查标识符使用
const identifierDiagnostics = checkIdentifierUsage(ast, definedVariables, shouldIgnoreError);
diagnostics.push(...identifierDiagnostics);
} catch (error: any) {
// 语法错误
let from = 0;
let to = text.length;
// 尝试解析位置信息
if (error.loc) {
const lines = text.split('\n');
from = lines.slice(0, error.loc.line - 1).join('\n').length + (error.loc.line > 1 ? 1 : 0) + error.loc.column;
to = from + 1;
} else if (error.pos !== undefined) {
from = error.pos;
to = from + 1;
}
diagnostics.push({
from,
to,
severity: 'error',
message: `Syntax error: ${error.message}`,
actions: [],
});
}
return diagnostics;
});
};