🐛 fix(editor): Add Go template editor component and update related schemas

This commit is contained in:
web 2025-08-12 05:47:58 -07:00 committed by speakeloudest
parent e7844d077b
commit 4a907257c6
9 changed files with 435 additions and 26 deletions

View File

@ -46,6 +46,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@workspace/ui/components/tooltip'; } from '@workspace/ui/components/tooltip';
import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button'; import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
import { GoTemplateEditor } from '@workspace/ui/custom-components/editor';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input'; import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon'; import { Icon } from '@workspace/ui/custom-components/icon';
import { UploadImage } from '@workspace/ui/custom-components/upload-image'; import { UploadImage } from '@workspace/ui/custom-components/upload-image';
@ -63,7 +64,7 @@ const createClientFormSchema = (t: any) =>
description: z.string().optional(), description: z.string().optional(),
icon: z.string().optional(), icon: z.string().optional(),
user_agent: z.string().min(1, `User-Agent ${t('form.validation.userAgentRequiredSuffix')}`), user_agent: z.string().min(1, `User-Agent ${t('form.validation.userAgentRequiredSuffix')}`),
proxy_template: z.string().default(''), schema: z.string().default(''),
template: z.string().default(''), template: z.string().default(''),
output_format: z.string().default(''), output_format: z.string().default(''),
download_link: z.object({ download_link: z.object({
@ -94,7 +95,7 @@ export function ProtocolForm() {
description: '', description: '',
icon: '', icon: '',
user_agent: '', user_agent: '',
proxy_template: '', schema: '',
template: '', template: '',
output_format: '', output_format: '',
download_link: { download_link: {
@ -230,7 +231,7 @@ export function ProtocolForm() {
description: '', description: '',
icon: '', icon: '',
user_agent: '', user_agent: '',
proxy_template: '', schema: '',
template: '', template: '',
output_format: '', output_format: '',
download_link: { download_link: {
@ -252,7 +253,7 @@ export function ProtocolForm() {
description: client.description || '', description: client.description || '',
icon: client.icon || '', icon: client.icon || '',
user_agent: client.user_agent, user_agent: client.user_agent,
proxy_template: client.proxy_template || '', schema: client.proxy_template || '',
template: client.template || '', template: client.template || '',
output_format: client.output_format || '', output_format: client.output_format || '',
download_link: { download_link: {
@ -301,6 +302,7 @@ export function ProtocolForm() {
if (editingClient) { if (editingClient) {
await updateSubscribeApplication({ await updateSubscribeApplication({
...data, ...data,
proxy_template: data.schema || '',
is_default: editingClient.is_default, is_default: editingClient.is_default,
id: editingClient.id, id: editingClient.id,
}); });
@ -308,6 +310,7 @@ export function ProtocolForm() {
} else { } else {
await createSubscribeApplication({ await createSubscribeApplication({
...data, ...data,
proxy_template: data.schema || '',
is_default: false, is_default: false,
}); });
toast.success(t('actions.createSuccess')); toast.success(t('actions.createSuccess'));
@ -491,20 +494,17 @@ export function ProtocolForm() {
<FormField <FormField
control={form.control} control={form.control}
name='proxy_template' name='schema'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('form.fields.proxyTemplate')}</FormLabel> <FormLabel>{t('form.fields.schema')}</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder='clash://install-config?url={url}&name={name}' placeholder='clash://install-config?url={url}&name={name}'
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>{t('form.descriptions.schema')}</FormDescription>
{t('form.descriptions.proxyTemplate')}
{`{url} {name}`}
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@ -517,10 +517,10 @@ export function ProtocolForm() {
<FormItem> <FormItem>
<FormLabel>{t('form.fields.template')}</FormLabel> <FormLabel>{t('form.fields.template')}</FormLabel>
<FormControl> <FormControl>
<Textarea <GoTemplateEditor
placeholder='proxies:\n - name: {name}\n type: {type}\n server: {server}\n port: {port}' enableSprig
className='min-h-[120px] font-mono text-sm' value={field.value || ''}
{...field} onChange={(value) => field.onChange(value)}
/> />
</FormControl> </FormControl>
<FormDescription>{t('form.descriptions.template')}</FormDescription> <FormDescription>{t('form.descriptions.template')}</FormDescription>

View File

@ -52,8 +52,8 @@
"name": "Name", "name": "Name",
"description": "Description", "description": "Description",
"outputFormat": "Output Format", "outputFormat": "Output Format",
"proxyTemplate": "Subscription Link", "schema": "URL Schema",
"template": "Config Template" "template": "Subscription File Template"
}, },
"descriptions": { "descriptions": {
"icon": "Icon URL or base64 encoding", "icon": "Icon URL or base64 encoding",
@ -61,8 +61,8 @@
"userAgentPrefix": "Client identifier for distinguishing different clients", "userAgentPrefix": "Client identifier for distinguishing different clients",
"description": "Detailed client description", "description": "Detailed client description",
"outputFormat": "Subscription configuration file format", "outputFormat": "Subscription configuration file format",
"proxyTemplate": "Subscription link template, supports variables: ", "schema": "URL Schema template, supports variables: '{url}' subscription URL, '{name}' site name",
"template": "Subscription configuration template, supports various variables", "template": "Subscription file template, supports various variables",
"downloadLink": "platform download URL" "downloadLink": "platform download URL"
}, },
"validation": { "validation": {

View File

@ -47,8 +47,8 @@
"icon": "图标 URL 或 base64 编码", "icon": "图标 URL 或 base64 编码",
"name": "客户端显示名称", "name": "客户端显示名称",
"outputFormat": "订阅配置文件格式", "outputFormat": "订阅配置文件格式",
"proxyTemplate": "订阅链接模板,支持变量:", "schema": "URL Schema 模板,支持变量:'{url}' 订阅地址,'{name}' 站点名称",
"template": "订阅配置模板,支持各种变量", "template": "订阅文件模板,支持各种变量",
"userAgentPrefix": "用于区分不同客户端的客户端标识符" "userAgentPrefix": "用于区分不同客户端的客户端标识符"
}, },
"editTitle": "编辑客户端", "editTitle": "编辑客户端",
@ -57,8 +57,8 @@
"icon": "图标", "icon": "图标",
"name": "名称", "name": "名称",
"outputFormat": "输出格式", "outputFormat": "输出格式",
"proxyTemplate": "订阅链接", "schema": "URL Schema",
"template": "配置模板" "template": "订阅文件模板"
}, },
"tabs": { "tabs": {
"basic": "基本信息", "basic": "基本信息",

View File

@ -1,5 +1,5 @@
// @ts-ignore // @ts-ignore
// API 更新时间: // API 更新时间:
// API 唯一标识: // API 唯一标识:
import * as ads from './ads'; import * as ads from './ads';

View File

@ -339,6 +339,7 @@ declare namespace API {
name: string; name: string;
description?: string; description?: string;
icon?: string; icon?: string;
scheme?: string;
user_agent: string; user_agent: string;
is_default: boolean; is_default: boolean;
proxy_template: string; proxy_template: string;
@ -1501,6 +1502,7 @@ declare namespace API {
name: string; name: string;
description?: string; description?: string;
icon?: string; icon?: string;
scheme?: string;
user_agent: string; user_agent: string;
is_default: boolean; is_default: boolean;
proxy_template: string; proxy_template: string;
@ -1779,6 +1781,7 @@ declare namespace API {
name: string; name: string;
description?: string; description?: string;
icon?: string; icon?: string;
scheme?: string;
user_agent: string; user_agent: string;
is_default: boolean; is_default: boolean;
proxy_template: string; proxy_template: string;

View File

@ -65,6 +65,7 @@
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.473.0", "lucide-react": "^0.473.0",
"mathjs": "^14.0.1", "mathjs": "^14.0.1",
"monaco-themes": "^0.4.6",
"motion": "^11.18.1", "motion": "^11.18.1",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
"react-day-picker": "8.10.1", "react-day-picker": "8.10.1",

View File

@ -0,0 +1,402 @@
'use client';
import { type Monaco } from '@monaco-editor/react';
import {
MonacoEditor,
MonacoEditorProps,
} from '@workspace/ui/custom-components/editor/monaco-editor';
import { useMemo } from 'react';
// Import Dracula theme
import DraculaTheme from 'monaco-themes/themes/Dracula.json' with { type: 'json' };
export interface GoTemplateEditorProps extends Omit<MonacoEditorProps, 'language'> {
schema?: Record<string, unknown>;
enableSprig?: boolean;
}
// Go template syntax keywords
const GO_TEMPLATE_KEYWORDS = [
'if',
'else',
'end',
'with',
'range',
'template',
'define',
'block',
'and',
'or',
'not',
'eq',
'ne',
'lt',
'le',
'gt',
'ge',
'print',
'printf',
'println',
];
// Sprig function list (commonly used)
const SPRIG_FUNCTIONS = [
// String functions
'trim',
'trimAll',
'trimSuffix',
'trimPrefix',
'upper',
'lower',
'title',
'untitle',
'repeat',
'substr',
'nospace',
'trunc',
'abbrev',
'abbrevboth',
'initials',
'randAlphaNum',
'randAlpha',
'randNumeric',
'wrap',
'wrapWith',
'contains',
'hasPrefix',
'hasSuffix',
'quote',
'squote',
'cat',
'indent',
'nindent',
'replace',
'plural',
'sha1sum',
'sha256sum',
'adler32sum',
'htmlEscape',
'htmlUnescape',
'urlEscape',
'urlUnescape',
// Math functions
'add',
'sub',
'mul',
'div',
'mod',
'max',
'min',
'ceil',
'floor',
'round',
// Date functions
'now',
'date',
'dateInZone',
'duration',
'durationRound',
'unixEpoch',
'dateModify',
'mustDateModify',
'htmlDate',
'htmlDateInZone',
'toDate',
'mustToDate',
// Lists and Dict functions
'list',
'first',
'rest',
'last',
'initial',
'reverse',
'uniq',
'compact',
'slice',
'has',
'set',
'unset',
'hasKey',
'pluck',
'keys',
'pick',
'omit',
'merge',
'mergeOverwrite',
'values',
'append',
'prepend',
'concat',
'dict',
'get',
'index',
'dig',
// Type functions
'typeOf',
'typeIs',
'typeIsLike',
'kindOf',
'kindIs',
'deepEqual',
// Default functions
'default',
'empty',
'coalesce',
'fromJson',
'toJson',
'toPrettyJson',
'toRawJson',
'mustFromJson',
'mustToJson',
'mustToPrettyJson',
// Encoding functions
'b64enc',
'b64dec',
'b32enc',
'b32dec',
// Flow control
'fail',
'required',
'tpl',
// UUID functions
'uuidv4',
// OS functions
'env',
'expandenv',
];
export function GoTemplateEditor({ schema, enableSprig = true, ...props }: GoTemplateEditorProps) {
const completionItems = useMemo(() => {
const items = [];
// Add Go template keywords
items.push(
...GO_TEMPLATE_KEYWORDS.map((keyword) => ({
label: keyword,
kind: 14,
insertText: keyword,
documentation: `Go template keyword: ${keyword}`,
})),
);
// Add Sprig functions if enabled
if (enableSprig) {
items.push(
...SPRIG_FUNCTIONS.map((func) => ({
label: func,
kind: 3, // Function
insertText: `${func} `,
documentation: `Sprig function: ${func}`,
})),
);
}
// Add schema field completion
if (schema && typeof schema === 'object') {
const addSchemaFields = (schemaObj: Record<string, unknown>, prefix = '.') => {
// Handle JSON Schema properties
if (schemaObj.properties && typeof schemaObj.properties === 'object') {
const properties = schemaObj.properties as Record<string, unknown>;
Object.keys(properties).forEach((key) => {
const property = properties[key] as Record<string, unknown>;
const fullPath = prefix === '.' ? `.${key}` : `${prefix}.${key}`;
const type = property.type || 'unknown';
items.push({
label: fullPath,
kind: 10, // Property
insertText: fullPath,
documentation: `Schema field: ${fullPath} (${type})${property.description ? ` - ${property.description}` : ''}`,
});
// Recursively add nested object properties
if (property.type === 'object' && property.properties) {
addSchemaFields(property, fullPath);
}
});
}
// Handle direct object structure (non-JSON Schema format)
else {
Object.keys(schemaObj).forEach((key) => {
if (key === 'properties' || key === 'type' || key === 'description') return;
const value = schemaObj[key];
const fullPath = prefix === '.' ? `.${key}` : `${prefix}.${key}`;
items.push({
label: fullPath,
kind: 10, // Property
insertText: fullPath,
documentation: `Schema field: ${fullPath} (${typeof value})`,
});
if (value && typeof value === 'object' && !Array.isArray(value)) {
addSchemaFields(value as Record<string, unknown>, fullPath);
}
});
}
};
addSchemaFields(schema);
}
return items;
}, [schema, enableSprig]);
const handleEditorMount = (editor: unknown, monaco: Monaco) => {
// Register custom Go template language
monaco.languages.register({ id: 'gotemplate' });
// Set syntax highlighting
monaco.languages.setMonarchTokensProvider('gotemplate', {
tokenizer: {
root: [
// Comments - match {{/*...*/}} first
[/\{\{\/\*[\s\S]*?\*\/\}\}/, 'comment'],
// Template tags - enter template state
[/\{\{-?/, { token: 'template-tag', next: '@template' }],
// Regular text
[/[^{]+/, ''],
[/[{]/, ''],
],
template: [
// Exit template
[/-?\}\}/, { token: 'template-tag', next: '@pop' }],
// Strings in template
[/"([^"\\]|\\.)*"/, 'string'],
[/'([^'\\]|\\.)*'/, 'string'],
// Go template keywords - exact word match
[new RegExp(`\\b(${GO_TEMPLATE_KEYWORDS.join('|')})\\b`), 'keyword'],
// Sprig functions - exact word match
[new RegExp(`\\b(${SPRIG_FUNCTIONS.join('|')})\\b`), 'function'],
// Variables starting with $
[/\$\w+/, 'variable'],
// Properties starting with .
[/\.\w+/, 'property'],
// Numbers
[/\d+(\.\d+)?/, 'number'],
// Operators and other tokens
[/[|:]/, 'operator'],
// Whitespace
[/\s+/, ''],
// Any other characters in template
[/./, ''],
],
string: [
[/[^\\"]+/, 'string'],
[/\\./, 'string.escape'],
[/"/, 'string', '@pop'],
],
string_single: [
[/[^\\']+/, 'string'],
[/\\./, 'string.escape'],
[/'/, 'string', '@pop'],
],
},
});
// Override the Dracula theme with Go template colors
monaco.editor.defineTheme('transparentTheme', {
base: DraculaTheme.base as 'vs' | 'vs-dark' | 'hc-black',
inherit: DraculaTheme.inherit,
rules: [
...DraculaTheme.rules,
{ token: 'template-tag', foreground: 'FFB86C', fontStyle: 'bold' }, // Dracula orange for template tags
{ token: 'keyword', foreground: 'FF79C6' }, // Dracula pink for keywords
{ token: 'function', foreground: '50FA7B' }, // Dracula green for functions
{ token: 'variable', foreground: 'F1FA8C' }, // Dracula yellow for variables
{ token: 'property', foreground: '8BE9FD' }, // Dracula cyan for properties
{ token: 'operator', foreground: 'FF79C6' }, // Dracula pink for operators
],
colors: {
'editor.background': '#00000000',
},
});
// Force theme refresh
const editorInstance = editor as { updateOptions?: (options: unknown) => void };
if (editorInstance && editorInstance.updateOptions) {
editorInstance.updateOptions({ theme: 'transparentTheme' });
}
// Register auto-completion
monaco.languages.registerCompletionItemProvider('gotemplate', {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provideCompletionItems: (model: any, position: any) => {
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: position.column,
endColumn: position.column,
};
return {
suggestions: completionItems.map((item) => ({
...item,
range,
})),
};
},
});
// Register hover provider
monaco.languages.registerHoverProvider('gotemplate', {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provideHover: (model: any, position: any) => {
const word = model.getWordAtPosition(position);
if (word) {
const wordText = word.word;
if (GO_TEMPLATE_KEYWORDS.includes(wordText)) {
return {
range: new monaco.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn,
),
contents: [{ value: `**Go Template Keyword**: ${wordText}` }],
};
}
if (SPRIG_FUNCTIONS.includes(wordText)) {
return {
range: new monaco.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn,
),
contents: [{ value: `**Sprig Function**: ${wordText}` }],
};
}
}
return null;
},
});
if (props.onMount) {
props.onMount(editor as Parameters<NonNullable<typeof props.onMount>>[0], monaco);
}
};
return (
<MonacoEditor
title='Go Template Editor'
description={`Go text/template syntax${enableSprig ? ' with Sprig functions' : ''}`}
{...props}
language='gotemplate'
placeholder='Enter your Go template here...'
onMount={handleEditorMount}
/>
);
}

View File

@ -1,3 +1,4 @@
export { GoTemplateEditor } from '@workspace/ui/custom-components/editor/go-template';
export { HTMLEditor } from '@workspace/ui/custom-components/editor/html'; export { HTMLEditor } from '@workspace/ui/custom-components/editor/html';
export { JSONEditor } from '@workspace/ui/custom-components/editor/json'; export { JSONEditor } from '@workspace/ui/custom-components/editor/json';
export { MarkdownEditor } from '@workspace/ui/custom-components/editor/markdown'; export { MarkdownEditor } from '@workspace/ui/custom-components/editor/markdown';

View File

@ -5,6 +5,7 @@ import { Button } from '@workspace/ui/components/button';
import { cn } from '@workspace/ui/lib/utils'; import { cn } from '@workspace/ui/lib/utils';
import { useSize } from 'ahooks'; import { useSize } from 'ahooks';
import { EyeIcon, EyeOff, FullscreenIcon, MinimizeIcon } from 'lucide-react'; import { EyeIcon, EyeOff, FullscreenIcon, MinimizeIcon } from 'lucide-react';
import DraculaTheme from 'monaco-themes/themes/Dracula.json' with { type: 'json' };
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
export interface MonacoEditorProps { export interface MonacoEditorProps {
@ -145,10 +146,11 @@ export function MonacoEditor({
theme='transparentTheme' theme='transparentTheme'
beforeMount={(monaco: Monaco) => { beforeMount={(monaco: Monaco) => {
monaco.editor.defineTheme('transparentTheme', { monaco.editor.defineTheme('transparentTheme', {
base: 'vs-dark', base: DraculaTheme.base as 'vs' | 'vs-dark' | 'hc-black',
inherit: true, inherit: DraculaTheme.inherit,
rules: [], rules: DraculaTheme.rules,
colors: { colors: {
...DraculaTheme.colors,
'editor.background': '#00000000', 'editor.background': '#00000000',
}, },
}); });