');
+ });
+
+ it('should fold regular tags with children', () => {
+ expect(fold('
');
+ });
+
+ 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: `${name}>` } };
+ }
+ }
+ 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",