mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-07-01 18:52:20 +08:00
feat: add js linter to code editor
This commit is contained in:
parent
de270ff258
commit
5006644732
@ -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
|
||||
|
@ -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:*",
|
||||
|
@ -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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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;
|
||||
});
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user