From 7fba9330949c780528786f7d72ffe306d08da112 Mon Sep 17 00:00:00 2001 From: Zeke Zhang <958414905@qq.com> Date: Fri, 26 Sep 2025 23:16:22 +0800 Subject: [PATCH] feat(code-editor): add support for HTML templates in JavaScript --- packages/core/client/package.json | 1 + .../__tests__/javascriptHtmlTemplate.test.ts | 41 ++++++++++++ .../src/flow/components/code-editor/index.tsx | 4 +- .../code-editor/javascriptHtmlTemplate.ts | 66 +++++++++++++++++++ 4 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 packages/core/client/src/flow/components/code-editor/__tests__/javascriptHtmlTemplate.test.ts create mode 100644 packages/core/client/src/flow/components/code-editor/javascriptHtmlTemplate.ts diff --git a/packages/core/client/package.json b/packages/core/client/package.json index e59310b6a94..6da6b48bec4 100644 --- a/packages/core/client/package.json +++ b/packages/core/client/package.json @@ -30,6 +30,7 @@ "@nocobase/evaluators": "1.9.0-alpha.16", "@nocobase/sdk": "1.9.0-alpha.16", "@nocobase/utils": "1.9.0-alpha.16", + "@codemirror/lang-html": "^6.4.10", "ahooks": "^3.7.2", "antd": "5.24.2", "antd-style": "3.7.1", diff --git a/packages/core/client/src/flow/components/code-editor/__tests__/javascriptHtmlTemplate.test.ts b/packages/core/client/src/flow/components/code-editor/__tests__/javascriptHtmlTemplate.test.ts new file mode 100644 index 00000000000..62bf039aa41 --- /dev/null +++ b/packages/core/client/src/flow/components/code-editor/__tests__/javascriptHtmlTemplate.test.ts @@ -0,0 +1,41 @@ +/** + * 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 { syntaxTree } from '@codemirror/language'; +import { EditorState } from '@codemirror/state'; +import { describe, expect, it } from 'vitest'; +import { javascriptWithHtmlTemplates } from '../javascriptHtmlTemplate'; + +describe('javascriptWithHtmlTemplates', () => { + it('mounts html parser for template literal segments', () => { + const support = javascriptWithHtmlTemplates(); + const state = EditorState.create({ + doc: 'const template = `
${value}
`;', + extensions: [support], + }); + + const htmlPos = state.doc.toString().indexOf('div class'); + const node = syntaxTree(state).resolveInner(htmlPos, 1); + + expect(node.name).toBe('TagName'); + }); + + it('preserves interpolation nodes from javascript parser', () => { + const support = javascriptWithHtmlTemplates(); + const state = EditorState.create({ + doc: 'const template = `
${value}
`;', + extensions: [support], + }); + + const variablePos = state.doc.toString().indexOf('value'); + const node = syntaxTree(state).resolveInner(variablePos, 1); + + expect(node.name).toBe('VariableName'); + }); +}); diff --git a/packages/core/client/src/flow/components/code-editor/index.tsx b/packages/core/client/src/flow/components/code-editor/index.tsx index dee5e8108a9..f1b64459af3 100644 --- a/packages/core/client/src/flow/components/code-editor/index.tsx +++ b/packages/core/client/src/flow/components/code-editor/index.tsx @@ -11,12 +11,12 @@ import React, { useEffect, useRef } from 'react'; // CodeMirror imports import { autocompletion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; -import { javascript } from '@codemirror/lang-javascript'; import { lintGutter } from '@codemirror/lint'; import { EditorState } from '@codemirror/state'; import { oneDark } from '@codemirror/theme-one-dark'; import { basicSetup, EditorView } from 'codemirror'; import completions from './completions'; +import { javascriptWithHtmlTemplates } from './javascriptHtmlTemplate'; import { createJavaScriptLinter } from './linter'; import { InjectableRendingEventTrigger, InjectableRendingEventTriggerProps } from '../decorator'; import { Flex } from 'antd'; @@ -83,7 +83,7 @@ const InnerCodeEditor: React.FC = ({ const extensions = [ basicSetup, - javascript(), + javascriptWithHtmlTemplates(), autocompletion({ override: [createCustomCompletion()], closeOnBlur: false, diff --git a/packages/core/client/src/flow/components/code-editor/javascriptHtmlTemplate.ts b/packages/core/client/src/flow/components/code-editor/javascriptHtmlTemplate.ts new file mode 100644 index 00000000000..16e09d50794 --- /dev/null +++ b/packages/core/client/src/flow/components/code-editor/javascriptHtmlTemplate.ts @@ -0,0 +1,66 @@ +/** + * 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 { html, htmlLanguage } from '@codemirror/lang-html'; +import { LanguageSupport } from '@codemirror/language'; +import { javascript, javascriptLanguage } from '@codemirror/lang-javascript'; +import { parseMixed } from '@lezer/common'; +import type { SyntaxNode } from '@lezer/common'; + +interface OverlayRange { + from: number; + to: number; +} + +function buildTemplateHtmlRanges(node: SyntaxNode): OverlayRange[] { + const ranges: OverlayRange[] = []; + const open = node.from + 1; + const close = node.to - 1; + let cursor = open; + + for (let child = node.firstChild; child; child = child.nextSibling) { + if (child.type.name === 'Interpolation') { + if (child.from > cursor) { + ranges.push({ from: cursor, to: child.from }); + } + cursor = Math.max(cursor, child.to); + } + } + + if (cursor < close) { + ranges.push({ from: cursor, to: close }); + } + + return ranges.filter((range) => range.to > range.from); +} + +export function javascriptWithHtmlTemplates(): LanguageSupport { + const baseJavascript = javascript(); + const htmlSupport = html(); + + const mixedLanguage = javascriptLanguage.configure({ + wrap: parseMixed((node) => { + if (node.type.name !== 'TemplateString') { + return null; + } + + const ranges = buildTemplateHtmlRanges(node.node); + if (!ranges.length) { + return null; + } + + return { + parser: htmlLanguage.parser, + overlay: ranges, + }; + }), + }); + + return new LanguageSupport(mixedLanguage, [baseJavascript.support, htmlSupport.support]); +}