diff --git a/common/changes/@coze-editor/code-language-typescript/feat-jsx-folding_2026-01-23-07-06.json b/common/changes/@coze-editor/code-language-typescript/feat-jsx-folding_2026-01-23-07-06.json new file mode 100644 index 00000000..f41d45a1 --- /dev/null +++ b/common/changes/@coze-editor/code-language-typescript/feat-jsx-folding_2026-01-23-07-06.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@coze-editor/code-language-typescript", + "comment": "support jsx folding", + "type": "minor" + } + ], + "packageName": "@coze-editor/code-language-typescript", + "email": "hanchayi@163.com" +} diff --git a/common/changes/@coze-editor/lang-javascript/feat-jsx-folding_2026-01-23-07-06.json b/common/changes/@coze-editor/lang-javascript/feat-jsx-folding_2026-01-23-07-06.json new file mode 100644 index 00000000..f7f9ab18 --- /dev/null +++ b/common/changes/@coze-editor/lang-javascript/feat-jsx-folding_2026-01-23-07-06.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@coze-editor/lang-javascript", + "comment": "support jsx folding", + "type": "minor" + } + ], + "packageName": "@coze-editor/lang-javascript", + "email": "hanchayi@163.com" +} diff --git a/common/changes/@coze-editor/preset-code-languages/feat-jsx-folding_2026-01-23-07-06.json b/common/changes/@coze-editor/preset-code-languages/feat-jsx-folding_2026-01-23-07-06.json new file mode 100644 index 00000000..518b651f --- /dev/null +++ b/common/changes/@coze-editor/preset-code-languages/feat-jsx-folding_2026-01-23-07-06.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@coze-editor/preset-code-languages", + "comment": "support jsx folding", + "type": "minor" + } + ], + "packageName": "@coze-editor/preset-code-languages", + "email": "hanchayi@163.com" +} diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 5125b24f..79e481bd 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -1379,9 +1379,9 @@ importers: ../../../packages/text-editor/code-language-typescript: dependencies: - '@codemirror/lang-javascript': - specifier: ^6.2.1 - version: 6.2.4 + '@coze-editor/lang-javascript': + specifier: workspace:* + version: link:../lang-javascript '@lukeed/uuid': specifier: ^2.0.1 version: 2.0.1 @@ -2186,6 +2186,49 @@ importers: specifier: ^5.8.2 version: 5.8.2 + ../../../packages/text-editor/lang-javascript: + dependencies: + '@codemirror/autocomplete': + specifier: ^6.18.0 + version: 6.18.6 + '@codemirror/language': + specifier: ^6.10.1 + version: 6.11.1 + '@codemirror/lint': + specifier: ^6.0.0 + version: 6.8.5 + '@codemirror/state': + specifier: ^6.4.1 + version: 6.5.2 + '@codemirror/view': + specifier: ^6.26.1 + version: 6.37.1 + '@lezer/common': + specifier: ^1.2.2 + version: 1.2.3 + '@lezer/javascript': + specifier: ^1.4.19 + version: 1.5.1 + devDependencies: + '@coze-arch/vitest-config': + specifier: workspace:* + version: link:../../../config/vitest-config + '@coze-editor/eslint-config': + specifier: workspace:* + version: link:../eslint-config + '@lezer/lr': + specifier: ^1.4.0 + version: 1.4.2 + '@vitest/coverage-v8': + specifier: ^3.0.9 + version: 3.0.9(vitest@3.0.9(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@22.13.13)(happy-dom@17.4.4)(jiti@2.6.1)(less@4.3.0)(sass-embedded@1.89.2)(sass@1.89.0)(sugarss@2.0.0)(terser@5.39.2)(yaml@2.8.0)) + eslint: + specifier: 9.14.0 + version: 9.14.0(jiti@2.6.1) + vitest: + specifier: ^3.0.9 + version: 3.0.9(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@22.13.13)(happy-dom@17.4.4)(jiti@2.6.1)(less@4.3.0)(sass-embedded@1.89.2)(sass@1.89.0)(sugarss@2.0.0)(terser@5.39.2)(tsx@4.19.3)(yaml@2.8.0) + ../../../packages/text-editor/lezer-parser-jinja2: dependencies: '@lezer/highlight': @@ -2482,9 +2525,6 @@ importers: '@codemirror/lang-html': specifier: ^6.4.9 version: 6.4.11 - '@codemirror/lang-javascript': - specifier: ^6.2.1 - version: 6.2.4 '@codemirror/lang-json': specifier: ^6.0.1 version: 6.0.2 @@ -2512,6 +2552,9 @@ importers: '@coze-editor/core': specifier: workspace:* version: link:../core + '@coze-editor/lang-javascript': + specifier: workspace:* + version: link:../lang-javascript devDependencies: '@codemirror/language': specifier: ^6.10.1 @@ -24930,7 +24973,7 @@ snapshots: '@typescript-eslint/utils@5.62.0(eslint@9.14.0(jiti@2.6.1))(typescript@5.8.2)': dependencies: - '@eslint-community/eslint-utils': 4.5.1(eslint@9.14.0(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.7.0(eslint@9.14.0(jiti@2.6.1)) '@types/json-schema': 7.0.15 '@types/semver': 7.5.8 '@typescript-eslint/scope-manager': 5.62.0 diff --git a/packages/text-editor/code-language-typescript/package.json b/packages/text-editor/code-language-typescript/package.json index e9901019..7d669418 100644 --- a/packages/text-editor/code-language-typescript/package.json +++ b/packages/text-editor/code-language-typescript/package.json @@ -17,7 +17,7 @@ "lint": "eslint && tsc --noEmit" }, "dependencies": { - "@codemirror/lang-javascript": "^6.2.1", + "@coze-editor/lang-javascript": "workspace:*", "@lukeed/uuid": "^2.0.1", "@typescript/vfs": "^1.6.1", "comlink": "^4.4.2", diff --git a/packages/text-editor/code-language-typescript/src/index.ts b/packages/text-editor/code-language-typescript/src/index.ts index 321e9ea7..8ac4daba 100644 --- a/packages/text-editor/code-language-typescript/src/index.ts +++ b/packages/text-editor/code-language-typescript/src/index.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 coze-dev // SPDX-License-Identifier: MIT -import { typescriptLanguage } from '@codemirror/lang-javascript'; +import { typescriptLanguage } from '@coze-editor/lang-javascript'; import { TypeScriptLanguageService, diff --git a/packages/text-editor/code-language-typescript/tsconfig.build.json b/packages/text-editor/code-language-typescript/tsconfig.build.json index 691e091a..62a82853 100644 --- a/packages/text-editor/code-language-typescript/tsconfig.build.json +++ b/packages/text-editor/code-language-typescript/tsconfig.build.json @@ -21,6 +21,9 @@ }, { "path": "../eslint-config/tsconfig.build.json" + }, + { + "path": "../lang-javascript/tsconfig.build.json" } ] } diff --git a/packages/text-editor/dev/src/index.tsx b/packages/text-editor/dev/src/index.tsx index 0ee94c86..832aa6d7 100644 --- a/packages/text-editor/dev/src/index.tsx +++ b/packages/text-editor/dev/src/index.tsx @@ -1,5 +1,5 @@ import { createRoot } from 'react-dom/client'; -import Page from './pages/chat'; +import Page from './pages/highlight'; import './index.css'; createRoot(document.getElementById('app')!).render(); diff --git a/packages/text-editor/dev/src/pages/highlight/examples.tsx b/packages/text-editor/dev/src/pages/highlight/examples.tsx index c1fcfc1f..aaa1f9c3 100644 --- a/packages/text-editor/dev/src/pages/highlight/examples.tsx +++ b/packages/text-editor/dev/src/pages/highlight/examples.tsx @@ -15,10 +15,17 @@ import React from 'react'; function App() { return ( -
-

Hello World

- {/* hello() */} -
+ <> +
+

Hello World

+ {/* hello() */} +
+ + ); } diff --git a/packages/text-editor/lang-javascript/.npmrc b/packages/text-editor/lang-javascript/.npmrc new file mode 100644 index 00000000..fb7f2353 --- /dev/null +++ b/packages/text-editor/lang-javascript/.npmrc @@ -0,0 +1 @@ +//registry.npmjs.org/:_authToken=${NPM_AUTH_TOKEN} diff --git a/packages/text-editor/lang-javascript/LICENSE b/packages/text-editor/lang-javascript/LICENSE new file mode 100644 index 00000000..6a84ba97 --- /dev/null +++ b/packages/text-editor/lang-javascript/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 coze-dev + +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. diff --git a/packages/text-editor/lang-javascript/README.md b/packages/text-editor/lang-javascript/README.md new file mode 100644 index 00000000..384a893a --- /dev/null +++ b/packages/text-editor/lang-javascript/README.md @@ -0,0 +1,7 @@ +## @coze-editor/lang-javascript + +This is a repo copy of [codemirror/lang-javascript](https://github.com/codemirror/lang-javascript/tree/78a85210d83c41bef23c222425f2ebf3d4353e2d). + +We have added some features to the original repo, such as: + +- JSX folding diff --git a/packages/text-editor/lang-javascript/__tests__/test-folding.test.ts b/packages/text-editor/lang-javascript/__tests__/test-folding.test.ts new file mode 100644 index 00000000..749fd1c6 --- /dev/null +++ b/packages/text-editor/lang-javascript/__tests__/test-folding.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { EditorState } from '@codemirror/state'; +import { foldable } from '@codemirror/language'; + +import { javascript } from '../src'; + +function jsxState(doc: string) { + return EditorState.create({ doc, extensions: [javascript({ jsx: true })] }); +} + +function fold(doc: string) { + const state = jsxState(doc); + const firstLine = doc.slice(0, doc.indexOf('\n')); + const folded = foldable(state, 0, firstLine.length); + if (folded) { + doc = `${doc.slice(0, folded.from)} ... ${doc.slice(folded.to)}`; + } + return doc; +} + +describe('JSX folding', () => { + it('should fold self-closing tags', () => { + expect(fold('
')).toBe('
'); + }); + + it('should fold self-closing tags with attributes', () => { + expect(fold('
')).toBe('
'); + }); + + it('should fold regular tags', () => { + expect(fold('
\n foo
')).toBe('
'); + }); + + it('should fold regular tags with attributes', () => { + expect(fold('
\nfoo
')).toBe('
'); + }); + + it('should fold regular tags with children', () => { + expect(fold('
\n
\nbar
')).toBe('
'); + }); + + it('should fold fragment tags', () => { + expect(fold('<> \nsdf ')).toBe('<> ... '); + }); + + it('should not fold inline tags', () => { + expect(fold('
')).toBe('
'); + }); +}); diff --git a/packages/text-editor/lang-javascript/config/rush-project.json b/packages/text-editor/lang-javascript/config/rush-project.json new file mode 100644 index 00000000..31290b5a --- /dev/null +++ b/packages/text-editor/lang-javascript/config/rush-project.json @@ -0,0 +1,12 @@ +{ + "operationSettings": [ + { + "operationName": "build", + "outputFolderNames": ["dist"] + }, + { + "operationName": "ts-check", + "outputFolderNames": ["./dist"] + } + ] +} diff --git a/packages/text-editor/lang-javascript/eslint.config.js b/packages/text-editor/lang-javascript/eslint.config.js new file mode 100644 index 00000000..cf9385eb --- /dev/null +++ b/packages/text-editor/lang-javascript/eslint.config.js @@ -0,0 +1,8 @@ +// Copyright (c) 2025 coze-dev +// SPDX-License-Identifier: MIT + +const { defineWebConfig } = require('@coze-editor/eslint-config'); + +module.exports = defineWebConfig({ + packageRoot: __dirname, +}); diff --git a/packages/text-editor/lang-javascript/package.json b/packages/text-editor/lang-javascript/package.json new file mode 100644 index 00000000..5c9bd8c5 --- /dev/null +++ b/packages/text-editor/lang-javascript/package.json @@ -0,0 +1,41 @@ +{ + "name": "@coze-editor/lang-javascript", + "version": "0.1.0-alpha.3d5d0a", + "description": "lang-javascript", + "license": "MIT", + "author": "zhangyi", + "maintainers": [], + "main": "./dist/esm/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "lint": "eslint && tsc --noEmit", + "test": "vitest --run --passWithNoTests", + "test:cov": "npm run test -- --coverage" + }, + "dependencies": { + "@codemirror/autocomplete": "^6.18.0", + "@codemirror/language": "^6.10.1", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.4.1", + "@codemirror/view": "^6.26.1", + "@lezer/common": "^1.2.2", + "@lezer/javascript": "^1.4.19" + }, + "devDependencies": { + "@coze-arch/vitest-config": "workspace:*", + "@coze-editor/eslint-config": "workspace:*", + "@lezer/lr": "^1.4.0", + "@vitest/coverage-v8": "^3.0.9", + "eslint": "9.14.0", + "vitest": "^3.0.9" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + } +} diff --git a/packages/text-editor/lang-javascript/src/complete.ts b/packages/text-editor/lang-javascript/src/complete.ts new file mode 100644 index 00000000..85b702b4 --- /dev/null +++ b/packages/text-editor/lang-javascript/src/complete.ts @@ -0,0 +1,285 @@ +import { + NodeWeakMap, + type SyntaxNodeRef, + type SyntaxNode, + IterMode, +} from '@lezer/common'; +import { type Text } from '@codemirror/state'; +import { syntaxTree } from '@codemirror/language'; +import { + type Completion, + type CompletionContext, + type CompletionResult, + type CompletionSource, +} from '@codemirror/autocomplete'; + +const cache = new NodeWeakMap(); + +const ScopeNodes = new Set([ + 'Script', + 'Block', + 'FunctionExpression', + 'FunctionDeclaration', + 'ArrowFunction', + 'MethodDeclaration', + 'ForStatement', +]); + +function defID(type: string) { + return ( + node: SyntaxNodeRef, + def: (node: SyntaxNodeRef, type: string) => void, + ) => { + const id = node.node.getChild('VariableDefinition'); + if (id) { + def(id, type); + } + return true; + }; +} + +const functionContext = ['FunctionDeclaration']; + +const gatherCompletions: { + [node: string]: ( + node: SyntaxNodeRef, + def: (node: SyntaxNodeRef, type: string) => void, + ) => void | boolean; +} = { + FunctionDeclaration: defID('function'), + ClassDeclaration: defID('class'), + ClassExpression: () => true, + EnumDeclaration: defID('constant'), + TypeAliasDeclaration: defID('type'), + NamespaceDeclaration: defID('namespace'), + VariableDefinition(node, def) { + if (!node.matchContext(functionContext)) { + def(node, 'variable'); + } + }, + TypeDefinition(node, def) { + def(node, 'type'); + }, + __proto__: null as any, +}; + +function getScope(doc: Text, node: SyntaxNode) { + const cached = cache.get(node); + if (cached) { + return cached; + } + + const completions: Completion[] = []; + let top = true; + function def(node: SyntaxNodeRef, type: string) { + const name = doc.sliceString(node.from, node.to); + completions.push({ label: name, type }); + } + node.cursor(IterMode.IncludeAnonymous).iterate(node => { + if (top) { + top = false; + } else if (node.name) { + const gather = gatherCompletions[node.name]; + if ((gather && gather(node, def)) || ScopeNodes.has(node.name)) { + return false; + } + } else if (node.to - node.from > 8192) { + // Allow caching for bigger internal nodes + for (const c of getScope(doc, node.node)) { + completions.push(c); + } + return false; + } + }); + cache.set(node, completions); + return completions; +} + +const Identifier = /^[\w$\xa1-\uffff][\w$\d\xa1-\uffff]*$/; + +export const dontComplete = [ + 'TemplateString', + 'String', + 'RegExp', + 'LineComment', + 'BlockComment', + 'VariableDefinition', + 'TypeDefinition', + 'Label', + 'PropertyDefinition', + 'PropertyName', + 'PrivatePropertyDefinition', + 'PrivatePropertyName', + 'JSXText', + 'JSXAttributeValue', + 'JSXOpenTag', + 'JSXCloseTag', + 'JSXSelfClosingTag', + '.', + '?.', +]; + +/// Completion source that looks up locally defined names in +/// JavaScript code. +export function localCompletionSource( + context: CompletionContext, +): CompletionResult | null { + const inner = syntaxTree(context.state).resolveInner(context.pos, -1); + if (dontComplete.indexOf(inner.name) > -1) { + return null; + } + const isWord = + inner.name == 'VariableName' || + (inner.to - inner.from < 20 && + Identifier.test(context.state.sliceDoc(inner.from, inner.to))); + if (!isWord && !context.explicit) { + return null; + } + let options: Completion[] = []; + for (let pos: SyntaxNode | null = inner; pos; pos = pos.parent) { + if (ScopeNodes.has(pos.name)) { + options = options.concat(getScope(context.state.doc, pos)); + } + } + return { + options, + from: isWord ? inner.from : context.pos, + validFor: Identifier, + }; +} + +function pathFor( + read: (node: SyntaxNode) => string, + member: SyntaxNode, + name: string, +) { + const path: string[] = []; + for (;;) { + const obj = member.firstChild; + let prop; + if (obj?.name == 'VariableName') { + path.push(read(obj)); + return { path: path.reverse(), name }; + } else if ( + obj?.name == 'MemberExpression' && + (prop = obj.lastChild)?.name == 'PropertyName' + ) { + path.push(read(prop!)); + member = obj; + } else { + return null; + } + } +} + +/// Helper function for defining JavaScript completion sources. It +/// returns the completable name and object path for a completion +/// context, or null if no name/property completion should happen at +/// that position. For example, when completing after `a.b.c` it will +/// return `{path: ["a", "b"], name: "c"}`. When completing after `x` +/// it will return `{path: [], name: "x"}`. When not in a property or +/// name, it will return null if `context.explicit` is false, and +/// `{path: [], name: ""}` otherwise. +export function completionPath( + context: CompletionContext, +): { path: readonly string[]; name: string } | null { + const read = (node: SyntaxNode) => + context.state.doc.sliceString(node.from, node.to); + const inner = syntaxTree(context.state).resolveInner(context.pos, -1); + if (inner.name == 'PropertyName') { + return pathFor(read, inner.parent!, read(inner)); + } else if ( + (inner.name == '.' || inner.name == '?.') && + inner.parent!.name == 'MemberExpression' + ) { + return pathFor(read, inner.parent!, ''); + } else if (dontComplete.indexOf(inner.name) > -1) { + return null; + } else if ( + inner.name == 'VariableName' || + (inner.to - inner.from < 20 && Identifier.test(read(inner))) + ) { + return { path: [], name: read(inner) }; + } else if (inner.name == 'MemberExpression') { + return pathFor(read, inner, ''); + } else { + return context.explicit ? { path: [], name: '' } : null; + } +} + +function enumeratePropertyCompletions( + obj: any, + top: boolean, +): readonly Completion[] { + const options: Completion[] = [], + seen: Set = new Set(); + for (let depth = 0; ; depth++) { + for (const name of (Object.getOwnPropertyNames || Object.keys)(obj)) { + if ( + !/^[a-zA-Z_$\xaa-\uffdc][\w$\xaa-\uffdc]*$/.test(name) || + seen.has(name) + ) { + continue; + } + seen.add(name); + let value; + try { + value = obj[name]; + } catch (_) { + continue; + } + options.push({ + label: name, + type: + typeof value === 'function' + ? /^[A-Z]/.test(name) + ? 'class' + : top + ? 'function' + : 'method' + : top + ? 'variable' + : 'property', + boost: -depth, + }); + } + const next = Object.getPrototypeOf(obj); + if (!next) { + return options; + } + obj = next; + } +} + +/// Defines a [completion source](#autocomplete.CompletionSource) that +/// completes from the given scope object (for example `globalThis`). +/// Will enter properties of the object when completing properties on +/// a directly-named path. +export function scopeCompletionSource(scope: any): CompletionSource { + const cache: Map = new Map(); + return (context: CompletionContext) => { + const path = completionPath(context); + if (!path) { + return null; + } + let target = scope; + for (const step of path.path) { + target = target[step]; + if (!target) { + return null; + } + } + let options = cache.get(target); + if (!options) { + cache.set( + target, + (options = enumeratePropertyCompletions(target, !path.path.length)), + ); + } + return { + from: context.pos - path.name.length, + options, + validFor: Identifier, + }; + }; +} diff --git a/packages/text-editor/lang-javascript/src/eslint.ts b/packages/text-editor/lang-javascript/src/eslint.ts new file mode 100644 index 00000000..18af1e24 --- /dev/null +++ b/packages/text-editor/lang-javascript/src/eslint.ts @@ -0,0 +1,102 @@ +import { type EditorView } from '@codemirror/view'; +import { type Text } from '@codemirror/state'; +import { type Diagnostic } from '@codemirror/lint'; + +import { javascriptLanguage } from './javascript'; + +/// Connects an [ESLint](https://eslint.org/) linter to CodeMirror's +/// [lint](#lint) integration. `eslint` should be an instance of the +/// [`Linter`](https://eslint.org/docs/developer-guide/nodejs-api#linter) +/// class, and `config` an optional ESLint configuration. The return +/// value of this function can be passed to [`linter`](#lint.linter) +/// to create a JavaScript linting extension. +/// +/// Note that ESLint targets node, and is tricky to run in the +/// browser. The +/// [eslint-linter-browserify](https://github.com/UziTech/eslint-linter-browserify) +/// package may help with that (see +/// [example](https://github.com/UziTech/eslint-linter-browserify/blob/master/example/script.js)). +export function esLint(eslint: any, config?: any) { + if (!config) { + config = { + parserOptions: { ecmaVersion: 2019, sourceType: 'module' }, + env: { + browser: true, + node: true, + es6: true, + es2015: true, + es2017: true, + es2020: true, + }, + rules: {}, + }; + eslint.getRules().forEach((desc: any, name: string) => { + if (desc.meta.docs?.recommended) { + config.rules[name] = 2; + } + }); + } + + return (view: EditorView) => { + const { state } = view, + found: Diagnostic[] = []; + for (const { from, to } of javascriptLanguage.findRegions(state)) { + const fromLine = state.doc.lineAt(from), + offset = { + line: fromLine.number - 1, + col: from - fromLine.from, + pos: from, + }; + for (const d of eslint.verify(state.sliceDoc(from, to), config)) { + found.push(translateDiagnostic(d, state.doc, offset)); + } + } + return found; + }; +} + +function mapPos( + line: number, + col: number, + doc: Text, + offset: { line: number; col: number; pos: number }, +) { + return ( + doc.line(line + offset.line).from + col + (line == 1 ? offset.col - 1 : -1) + ); +} + +function translateDiagnostic( + input: any, + doc: Text, + offset: { line: number; col: number; pos: number }, +): Diagnostic { + const start = mapPos(input.line, input.column, doc, offset); + const result: Diagnostic = { + from: start, + to: + input.endLine != null && input.endColumn != 1 + ? mapPos(input.endLine, input.endColumn, doc, offset) + : start, + message: input.message, + source: input.ruleId ? `eslint:${input.ruleId}` : 'eslint', + severity: input.severity == 1 ? 'warning' : 'error', + }; + if (input.fix) { + const { range, text } = input.fix, + from = range[0] + offset.pos - start, + to = range[1] + offset.pos - start; + result.actions = [ + { + name: 'fix', + apply(view: EditorView, start: number) { + view.dispatch({ + changes: { from: start + from, to: start + to, insert: text }, + scrollIntoView: true, + }); + }, + }, + ]; + } + return result; +} diff --git a/packages/text-editor/lang-javascript/src/index.ts b/packages/text-editor/lang-javascript/src/index.ts new file mode 100644 index 00000000..75770ff6 --- /dev/null +++ b/packages/text-editor/lang-javascript/src/index.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2025 coze-dev +// SPDX-License-Identifier: MIT + +export { + javascriptLanguage, + typescriptLanguage, + jsxLanguage, + tsxLanguage, + autoCloseTags, + javascript, +} from './javascript'; +export { snippets, typescriptSnippets } from './snippets'; +export { esLint } from './eslint'; +export { + localCompletionSource, + completionPath, + scopeCompletionSource, +} from './complete'; diff --git a/packages/text-editor/lang-javascript/src/javascript.ts b/packages/text-editor/lang-javascript/src/javascript.ts new file mode 100644 index 00000000..5d97f2ea --- /dev/null +++ b/packages/text-editor/lang-javascript/src/javascript.ts @@ -0,0 +1,278 @@ +/* eslint-disable complexity */ +import { parser } from '@lezer/javascript'; +import { type SyntaxNode } from '@lezer/common'; +import { EditorView } from '@codemirror/view'; +import { EditorSelection, type Text } from '@codemirror/state'; +import { + LRLanguage, + LanguageSupport, + type Sublanguage, + sublanguageProp, + defineLanguageFacet, + delimitedIndent, + flatIndent, + continuedIndent, + indentNodeProp, + foldNodeProp, + foldInside, + syntaxTree, +} from '@codemirror/language'; +import { completeFromList, ifNotIn } from '@codemirror/autocomplete'; + +import { snippets, typescriptSnippets } from './snippets'; +import { localCompletionSource, dontComplete } from './complete'; + +/// A language provider based on the [Lezer JavaScript +/// parser](https://github.com/lezer-parser/javascript), extended with +/// highlighting and indentation information. +export const javascriptLanguage = LRLanguage.define({ + name: 'javascript', + parser: parser.configure({ + props: [ + indentNodeProp.add({ + IfStatement: continuedIndent({ except: /^\s*({|else\b)/ }), + TryStatement: continuedIndent({ except: /^\s*({|catch\b|finally\b)/ }), + LabeledStatement: flatIndent, + SwitchBody: context => { + const after = context.textAfter, + closed = /^\s*\}/.test(after), + isCase = /^\s*(case|default)\b/.test(after); + return ( + context.baseIndent + (closed ? 0 : isCase ? 1 : 2) * context.unit + ); + }, + Block: delimitedIndent({ closing: '}' }), + ArrowFunction: cx => cx.baseIndent + cx.unit, + 'TemplateString BlockComment': () => null, + 'Statement Property': continuedIndent({ except: /^\s*{/ }), + JSXElement(context) { + const closed = /^\s*<\//.test(context.textAfter); + return ( + context.lineIndent(context.node.from) + (closed ? 0 : context.unit) + ); + }, + JSXEscape(context) { + const closed = /\s*\}/.test(context.textAfter); + return ( + context.lineIndent(context.node.from) + (closed ? 0 : context.unit) + ); + }, + 'JSXOpenTag JSXSelfClosingTag'(context) { + return context.column(context.node.from) + context.unit; + }, + }), + foldNodeProp.add({ + 'Block ClassBody SwitchBody EnumBody ObjectExpression ArrayExpression ObjectType': + foldInside, + BlockComment(tree) { + return { from: tree.from + 2, to: tree.to - 2 }; + }, + JSXElement(node) { + const first = node.firstChild; + const start = first?.firstChild ? first.firstChild.nextSibling : null; + + let end = null; + + if (!first) { + return null; + } + + if (first.name === 'JSXSelfClosingTag') { + end = first.lastChild; + } + + if (['JSXOpenTag', 'JSXFragmentTag'].includes(first.name)) { + end = + node.lastChild?.name === 'JSXCloseTag' ? node.lastChild : null; + } + + if (!start || !end) { + return null; + } + + return start && start.to < end.from + ? { from: start.to, to: end.type.isError ? node.to : end.from } + : null; + }, + }), + ], + }), + languageData: { + closeBrackets: { brackets: ['(', '[', '{', "'", '"', '`'] }, + commentTokens: { line: '//', block: { open: '/*', close: '*/' } }, + indentOnInput: /^\s*(?:case |default:|\{|\}|<\/)$/, + wordChars: '$', + }, +}); + +const jsxSublanguage: Sublanguage = { + test: node => /^JSX/.test(node.name), + facet: defineLanguageFacet({ + commentTokens: { block: { open: '{/*', close: '*/}' } }, + }), +}; + +/// A language provider for TypeScript. +export const typescriptLanguage = javascriptLanguage.configure( + { dialect: 'ts' }, + 'typescript', +); + +/// Language provider for JSX. +export const jsxLanguage = javascriptLanguage.configure({ + dialect: 'jsx', + props: [sublanguageProp.add(n => (n.isTop ? [jsxSublanguage] : undefined))], +}); + +/// Language provider for JSX + TypeScript. +export const tsxLanguage = javascriptLanguage.configure( + { + dialect: 'jsx ts', + props: [sublanguageProp.add(n => (n.isTop ? [jsxSublanguage] : undefined))], + }, + 'typescript', +); + +const kwCompletion = (name: string) => ({ label: name, type: 'keyword' }); + +const keywords = + 'break case const continue default delete export extends false finally in instanceof let new return static super switch this throw true typeof var yield' + .split(' ') + .map(kwCompletion); +const typescriptKeywords = keywords.concat( + ['declare', 'implements', 'private', 'protected', 'public'].map(kwCompletion), +); + +/// JavaScript support. Includes [snippet](#lang-javascript.snippets) +/// and local variable completion. +export function javascript( + config: { jsx?: boolean; typescript?: boolean } = {}, +) { + const lang = config.jsx + ? config.typescript + ? tsxLanguage + : jsxLanguage + : config.typescript + ? typescriptLanguage + : javascriptLanguage; + const completions = config.typescript + ? typescriptSnippets.concat(typescriptKeywords) + : snippets.concat(keywords); + return new LanguageSupport(lang, [ + javascriptLanguage.data.of({ + autocomplete: ifNotIn(dontComplete, completeFromList(completions)), + }), + javascriptLanguage.data.of({ + autocomplete: localCompletionSource, + }), + config.jsx ? autoCloseTags : [], + ]); +} + +function findOpenTag(node: SyntaxNode) { + for (;;) { + if ( + node.name == 'JSXOpenTag' || + node.name == 'JSXSelfClosingTag' || + node.name == 'JSXFragmentTag' + ) { + return node; + } + if (node.name == 'JSXEscape' || !node.parent) { + return null; + } + node = node.parent; + } +} + +function elementName( + doc: Text, + tree: SyntaxNode | null | undefined, + max = doc.length, +) { + for (let ch = tree?.firstChild; ch; ch = ch.nextSibling) { + if ( + ch.name == 'JSXIdentifier' || + ch.name == 'JSXBuiltin' || + ch.name == 'JSXNamespacedName' || + ch.name == 'JSXMemberExpression' + ) { + return doc.sliceString(ch.from, Math.min(ch.to, max)); + } + } + return ''; +} + +const android = + typeof navigator === 'object' && /Android\b/.test(navigator.userAgent); + +/// Extension that will automatically insert JSX close tags when a `>` or +/// `/` is typed. +export const autoCloseTags = EditorView.inputHandler.of( + (view, from, to, text, defaultInsert) => { + if ( + (android ? view.composing : view.compositionStarted) || + view.state.readOnly || + from != to || + (text != '>' && text != '/') || + !javascriptLanguage.isActiveAt(view.state, from, -1) + ) { + return false; + } + const base = defaultInsert(), + { state } = base; + const closeTags = state.changeByRange(range => { + const { head } = range; + let around = syntaxTree(state).resolveInner(head - 1, -1); + let name; + if (around.name == 'JSXStartTag') { + around = around.parent!; + } + if ( + state.doc.sliceString(head - 1, head) != text || + (around.name == 'JSXAttributeValue' && around.to > head) + ) { + // Ignore input inside attribute or cases where the text wasn't actually inserted + } else if (text == '>' && around.name == 'JSXFragmentTag') { + return { range, changes: { from: head, insert: '' } }; + } else if (text == '/' && around.name == 'JSXStartCloseTag') { + const empty = around.parent!, + base = empty.parent; + if ( + base && + empty.from == head - 2 && + ((name = elementName(state.doc, base.firstChild, head)) || + base.firstChild?.name == 'JSXFragmentTag') + ) { + const insert = `${name}>`; + return { + range: EditorSelection.cursor(head + insert.length, -1), + changes: { from: head, insert }, + }; + } + } else if (text == '>') { + const openTag = findOpenTag(around); + if ( + openTag && + openTag.name == 'JSXOpenTag' && + !/^\/?>|^<\//.test(state.doc.sliceString(head, head + 2)) && + (name = elementName(state.doc, openTag, head)) + ) { + return { range, changes: { from: head, insert: `` } }; + } + } + return { range }; + }); + if (closeTags.changes.empty) { + return false; + } + view.dispatch([ + base, + state.update(closeTags, { + userEvent: 'input.complete', + scrollIntoView: true, + }), + ]); + return true; + }, +); diff --git a/packages/text-editor/lang-javascript/src/snippets.ts b/packages/text-editor/lang-javascript/src/snippets.ts new file mode 100644 index 00000000..1e27ac69 --- /dev/null +++ b/packages/text-editor/lang-javascript/src/snippets.ts @@ -0,0 +1,84 @@ +import { + type Completion, + snippetCompletion as snip, +} from '@codemirror/autocomplete'; + +/// A collection of JavaScript-related +/// [snippets](#autocomplete.snippet). +export const snippets: readonly Completion[] = [ + snip('function ${name}(${params}) {\n\t${}\n}', { + label: 'function', + detail: 'definition', + type: 'keyword', + }), + snip('for (let ${index} = 0; ${index} < ${bound}; ${index}++) {\n\t${}\n}', { + label: 'for', + detail: 'loop', + type: 'keyword', + }), + snip('for (let ${name} of ${collection}) {\n\t${}\n}', { + label: 'for', + detail: 'of loop', + type: 'keyword', + }), + snip('do {\n\t${}\n} while (${})', { + label: 'do', + detail: 'loop', + type: 'keyword', + }), + snip('while (${}) {\n\t${}\n}', { + label: 'while', + detail: 'loop', + type: 'keyword', + }), + snip('try {\n\t${}\n} catch (${error}) {\n\t${}\n}', { + label: 'try', + detail: '/ catch block', + type: 'keyword', + }), + snip('if (${}) {\n\t${}\n}', { + label: 'if', + detail: 'block', + type: 'keyword', + }), + snip('if (${}) {\n\t${}\n} else {\n\t${}\n}', { + label: 'if', + detail: '/ else block', + type: 'keyword', + }), + snip('class ${name} {\n\tconstructor(${params}) {\n\t\t${}\n\t}\n}', { + label: 'class', + detail: 'definition', + type: 'keyword', + }), + snip('import {${names}} from "${module}"\n${}', { + label: 'import', + detail: 'named', + type: 'keyword', + }), + snip('import ${name} from "${module}"\n${}', { + label: 'import', + detail: 'default', + type: 'keyword', + }), +]; + +/// A collection of snippet completions for TypeScript. Includes the +/// JavaScript [snippets](#lang-javascript.snippets). +export const typescriptSnippets = snippets.concat([ + snip('interface ${name} {\n\t${}\n}', { + label: 'interface', + detail: 'definition', + type: 'keyword', + }), + snip('type ${name} = ${type}', { + label: 'type', + detail: 'definition', + type: 'keyword', + }), + snip('enum ${name} {\n\t${}\n}', { + label: 'enum', + detail: 'definition', + type: 'keyword', + }), +]); diff --git a/packages/text-editor/lang-javascript/tsconfig.build.json b/packages/text-editor/lang-javascript/tsconfig.build.json new file mode 100644 index 00000000..eeb18992 --- /dev/null +++ b/packages/text-editor/lang-javascript/tsconfig.build.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@coze-arch/ts-config/tsconfig.web.json", + "compilerOptions": { + "types": [], + "strictNullChecks": true, + "noImplicitAny": true, + "moduleResolution": "bundler", + "module": "ESNext", + "rootDir": "./src", + "outDir": "./dist", + "tsBuildInfoFile": "./dist/tsconfig.build.tsbuildinfo" + }, + "include": ["src"], + "references": [ + { + "path": "../../../config/vitest-config/tsconfig.build.json" + }, + { + "path": "../eslint-config/tsconfig.build.json" + } + ] +} diff --git a/packages/text-editor/lang-javascript/tsconfig.json b/packages/text-editor/lang-javascript/tsconfig.json new file mode 100644 index 00000000..b3951a30 --- /dev/null +++ b/packages/text-editor/lang-javascript/tsconfig.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "exclude": ["**/*"], + "compilerOptions": { + "composite": true + }, + "references": [ + { + "path": "./tsconfig.build.json" + }, + { + "path": "./tsconfig.misc.json" + } + ] +} diff --git a/packages/text-editor/lang-javascript/tsconfig.misc.json b/packages/text-editor/lang-javascript/tsconfig.misc.json new file mode 100644 index 00000000..a9af4794 --- /dev/null +++ b/packages/text-editor/lang-javascript/tsconfig.misc.json @@ -0,0 +1,24 @@ +{ + "extends": "@coze-arch/ts-config/tsconfig.web.json", + "$schema": "https://json.schemastore.org/tsconfig", + "include": ["__tests__", "vitest.config.ts"], + "exclude": ["**/node_modules", "./dist"], + "references": [ + { + "path": "./tsconfig.build.json" + } + ], + "compilerOptions": { + "baseUrl": "./", + "types": ["react", "react-dom"], + "jsx": "react", + "isolatedModules": true, + "strictNullChecks": true, + "strictPropertyInitialization": false, + "paths": { + "@/*": ["./src/*"] + }, + "rootDir": "./", + "outDir": "./dist" + } +} diff --git a/packages/text-editor/lang-javascript/tsup.config.ts b/packages/text-editor/lang-javascript/tsup.config.ts new file mode 100644 index 00000000..f5e07d1e --- /dev/null +++ b/packages/text-editor/lang-javascript/tsup.config.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2025 coze-dev +// SPDX-License-Identifier: MIT + +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: [ + 'src/index.ts' + ], + format: ['cjs', 'esm'], + sourcemap: true, + legacyOutput: true, + clean: true, + dts: { + resolve: true, + compilerOptions: { + composite: false, + moduleResolution: 'bundler', + module: 'esnext', + }, + }, +}); diff --git a/packages/text-editor/lang-javascript/vitest.config.ts b/packages/text-editor/lang-javascript/vitest.config.ts new file mode 100644 index 00000000..f33fbc4d --- /dev/null +++ b/packages/text-editor/lang-javascript/vitest.config.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2025 coze-dev +// SPDX-License-Identifier: MIT + +import { defineConfig } from '@coze-arch/vitest-config'; + +export default defineConfig({ + dirname: __dirname, + preset: 'web', +}); diff --git a/packages/text-editor/preset-code-languages/package.json b/packages/text-editor/preset-code-languages/package.json index 8ddb0883..652b7b54 100644 --- a/packages/text-editor/preset-code-languages/package.json +++ b/packages/text-editor/preset-code-languages/package.json @@ -25,7 +25,6 @@ "@codemirror/lang-cpp": "^6.0.2", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.9", - "@codemirror/lang-javascript": "^6.2.1", "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-markdown": "^6.3.2", "@codemirror/lang-python": "^6.1.7", @@ -34,7 +33,8 @@ "@codemirror/lang-vue": "^0.1.3", "@codemirror/lang-wast": "^6.0.2", "@coze-editor/code-language-shell": "workspace:*", - "@coze-editor/core": "workspace:*" + "@coze-editor/core": "workspace:*", + "@coze-editor/lang-javascript": "workspace:*" }, "devDependencies": { "@codemirror/language": "^6.10.1", diff --git a/packages/text-editor/preset-code-languages/src/languages.ts b/packages/text-editor/preset-code-languages/src/languages.ts index e4a86750..94a37967 100644 --- a/packages/text-editor/preset-code-languages/src/languages.ts +++ b/packages/text-editor/preset-code-languages/src/languages.ts @@ -13,7 +13,7 @@ export const supportedLanguages = [ name: 'TS', extensions: ['ts'], async load() { - return import('@codemirror/lang-javascript').then(module => + return import('@coze-editor/lang-javascript').then(module => module.javascript({ typescript: true }), ); }, @@ -22,7 +22,7 @@ export const supportedLanguages = [ name: 'JS', extensions: ['js', 'mjs', 'cjs'], async load() { - return import('@codemirror/lang-javascript').then(module => + return import('@coze-editor/lang-javascript').then(module => module.javascript(), ); }, @@ -31,7 +31,7 @@ export const supportedLanguages = [ name: 'TSX', extensions: ['tsx'], async load() { - return import('@codemirror/lang-javascript').then(module => + return import('@coze-editor/lang-javascript').then(module => module.javascript({ jsx: true, typescript: true, @@ -43,7 +43,7 @@ export const supportedLanguages = [ name: 'JSX', extensions: ['jsx'], async load() { - return import('@codemirror/lang-javascript').then(module => + return import('@coze-editor/lang-javascript').then(module => module.javascript({ jsx: true }), ); }, diff --git a/packages/text-editor/preset-code-languages/tsconfig.build.json b/packages/text-editor/preset-code-languages/tsconfig.build.json index f5dd5d8f..5f3472c8 100644 --- a/packages/text-editor/preset-code-languages/tsconfig.build.json +++ b/packages/text-editor/preset-code-languages/tsconfig.build.json @@ -24,6 +24,9 @@ }, { "path": "../eslint-config/tsconfig.build.json" + }, + { + "path": "../lang-javascript/tsconfig.build.json" } ] } diff --git a/rush.json b/rush.json index 36f707df..309872f1 100644 --- a/rush.json +++ b/rush.json @@ -706,6 +706,12 @@ "shouldPublish": true, "tags": ["level-3"] }, + { + "packageName": "@coze-editor/lang-javascript", + "projectFolder": "packages/text-editor/lang-javascript", + "shouldPublish": true, + "tags": ["level-3"] + }, { "packageName": "@coze-editor/lezer-parser-template", "projectFolder": "packages/text-editor/lezer-parser-template",