diff --git a/apps/admin/app/dashboard/subscribe/protocol-form.tsx b/apps/admin/app/dashboard/subscribe/protocol-form.tsx
index 0ff4930..250d357 100644
--- a/apps/admin/app/dashboard/subscribe/protocol-form.tsx
+++ b/apps/admin/app/dashboard/subscribe/protocol-form.tsx
@@ -519,6 +519,93 @@ export function ProtocolForm() {
{t('form.fields.template')}
field.onChange(value)}
diff --git a/packages/ui/src/custom-components/editor/go-template.tsx b/packages/ui/src/custom-components/editor/go-template.tsx
index f685439..48624ea 100644
--- a/packages/ui/src/custom-components/editor/go-template.tsx
+++ b/packages/ui/src/custom-components/editor/go-template.tsx
@@ -5,16 +5,162 @@ import {
MonacoEditor,
MonacoEditorProps,
} from '@workspace/ui/custom-components/editor/monaco-editor';
-import { useMemo } from 'react';
-// Import Dracula theme
+import * as monaco from 'monaco-editor';
import DraculaTheme from 'monaco-themes/themes/Dracula.json' with { type: 'json' };
+import { useEffect, useRef } from 'react';
+
+type SchemaType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'null';
+
+interface SchemaProperty {
+ type: SchemaType;
+ items?: SchemaProperty;
+ properties?: Record;
+ description?: string;
+}
export interface GoTemplateEditorProps extends Omit {
- schema?: Record;
+ schema?: Record | Record;
enableSprig?: boolean;
}
-// Go template syntax keywords
+interface CompletionItem {
+ label: string;
+ kind: number;
+ insertText: string;
+ documentation: string;
+ sortText: string;
+}
+
+const SORT_PREFIXES = {
+ RANGE_VAR: 'aa_var_',
+ NESTED: 'a_nested_',
+ CURRENT: 'a_current_',
+ VAR_FIELD: 'b_var_',
+ ROOT_FIELD: 'c_field_',
+ RANGE_OP: 'd_range_',
+ WITH_OP: 'd_with_',
+ ROOT_IN_RANGE: 'y_root_',
+ KEYWORD: 'z_keyword_',
+ SPRIG: 'zz_sprig_',
+} as const;
+
+const COMPLETION_KINDS = {
+ VARIABLE: 6,
+ PROPERTY: 10,
+ FUNCTION: 3,
+ KEYWORD: 14,
+} as const;
+
+const MATCH_SCORES = {
+ EXACT: 1000,
+ DOT_FIELD: 1000,
+ PREFIX_BASE: 800,
+ DOT_VARIABLE: 500,
+ INCLUDE_BASE: 500,
+ FUZZY_BASE: 100,
+ OTHER: 100,
+} as const;
+
+const REGEX_PATTERNS = {
+ RANGE_ASSIGNMENT: /(?:\$\w+\s*,\s*)?(\$\w+)\s*:=\s*(\.\w+(?:\.\w+)*)/,
+ FIELD_PATH: /^(\.\w+(?:\.\w+)*)$/,
+ NESTED_DOT: /(\.\w+(?:\.\w+)*)\./,
+ NESTED_VAR: /(\$\w+\.\w+(?:\.\w+)*)\.$/,
+ NESTED_GENERAL: /([\w.]+)\.$/,
+ WORD_WITH_SPACES: /(\s*)(\S*)$/,
+ LEADING_SPACES: /^(\s+)/,
+ DOT_VAR_CLEAN: /^[.$]/,
+} as const;
+
+const COMPLETION_SNIPPETS = {
+ IF_BLOCK: {
+ label: 'if...end',
+ kind: 15,
+ insertText: 'if ${1:condition}\n\t$0\nend',
+ insertTextRules: 4,
+ documentation: 'Create an if block',
+ sortText: `${SORT_PREFIXES.KEYWORD}if_block`,
+ },
+ RANGE_BLOCK: {
+ label: 'range...end',
+ kind: 15,
+ insertText: 'range ${1:.items}\n\t$0\nend',
+ insertTextRules: 4,
+ documentation: 'Create a range loop block',
+ sortText: `${SORT_PREFIXES.KEYWORD}range_block`,
+ },
+ WITH_BLOCK: {
+ label: 'with...end',
+ kind: 15,
+ insertText: 'with ${1:.field}\n\t$0\nend',
+ insertTextRules: 4,
+ documentation: 'Create a with block',
+ sortText: `${SORT_PREFIXES.KEYWORD}with_block`,
+ },
+} as const;
+
+const calculateMatchScore = (label: string, searchText: string): number => {
+ if (!searchText) return 0;
+
+ if (searchText === '.') {
+ if (label.startsWith('.')) return MATCH_SCORES.DOT_FIELD;
+ if (label.startsWith('$')) return MATCH_SCORES.DOT_VARIABLE;
+ return MATCH_SCORES.OTHER;
+ }
+
+ const labelLower = label.toLowerCase();
+ const searchLower = searchText.toLowerCase();
+ const cleanLabel = labelLower.replace(REGEX_PATTERNS.DOT_VAR_CLEAN, '');
+ const cleanSearch = searchLower.replace(REGEX_PATTERNS.DOT_VAR_CLEAN, '');
+
+ if (cleanLabel === cleanSearch || labelLower === searchLower) {
+ return MATCH_SCORES.EXACT;
+ }
+
+ if (cleanLabel.startsWith(cleanSearch) || labelLower.startsWith(searchLower)) {
+ const matchLength = Math.max(cleanSearch.length, searchLower.length);
+ const totalLength = Math.max(cleanLabel.length, labelLower.length);
+ return MATCH_SCORES.PREFIX_BASE + (matchLength / totalLength) * 100;
+ }
+
+ let includeIndex = cleanLabel.indexOf(cleanSearch);
+ if (includeIndex === -1) {
+ includeIndex = labelLower.indexOf(searchLower);
+ }
+
+ if (includeIndex !== -1) {
+ const totalLength = Math.max(cleanLabel.length, labelLower.length);
+ const positionScore = ((totalLength - includeIndex) / totalLength) * 50;
+ const matchLength = Math.max(cleanSearch.length, searchLower.length);
+ const lengthScore = (matchLength / totalLength) * 100;
+ return MATCH_SCORES.INCLUDE_BASE + positionScore + lengthScore;
+ }
+
+ let fuzzyScore = 0;
+ let searchIndex = 0;
+ const targetText = cleanSearch ? cleanLabel : labelLower;
+ const searchQuery = cleanSearch || searchLower;
+
+ for (let i = 0; i < targetText.length && searchIndex < searchQuery.length; i++) {
+ if (targetText[i] === searchQuery[searchIndex]) {
+ fuzzyScore += 10;
+ searchIndex++;
+ }
+ }
+
+ return searchIndex === searchQuery.length ? MATCH_SCORES.FUZZY_BASE + fuzzyScore : 0;
+};
+
+const generateDynamicSortText = (
+ item: CompletionItem,
+ searchText: string,
+ baseCategory: string,
+): string => {
+ const matchScore = calculateMatchScore(item.label, searchText);
+ const scorePrefix = (10000 - matchScore).toString().padStart(5, '0');
+ return `${scorePrefix}_${baseCategory}_${item.label}`;
+};
+
const GO_TEMPLATE_KEYWORDS = [
'if',
'else',
@@ -24,23 +170,25 @@ const GO_TEMPLATE_KEYWORDS = [
'template',
'define',
'block',
+ 'include',
+ 'not',
'and',
'or',
- 'not',
'eq',
'ne',
'lt',
'le',
'gt',
'ge',
- 'print',
+ 'len',
+ 'index',
+ 'slice',
'printf',
+ 'print',
'println',
];
-// Sprig function list (commonly used)
const SPRIG_FUNCTIONS = [
- // String functions
'trim',
'trimAll',
'trimSuffix',
@@ -59,6 +207,7 @@ const SPRIG_FUNCTIONS = [
'randAlphaNum',
'randAlpha',
'randNumeric',
+ 'randAscii',
'wrap',
'wrapWith',
'contains',
@@ -71,28 +220,44 @@ const SPRIG_FUNCTIONS = [
'nindent',
'replace',
'plural',
- 'sha1sum',
- 'sha256sum',
- 'adler32sum',
- 'htmlEscape',
- 'htmlUnescape',
- 'urlEscape',
- 'urlUnescape',
+ 'snakecase',
+ 'camelcase',
+ 'kebabcase',
+ 'swapcase',
+ 'shuffle',
+
+ 'splitList',
+ 'split',
+ 'join',
+ 'sortAlpha',
- // Math functions
'add',
+ 'add1',
'sub',
- 'mul',
'div',
'mod',
+ 'mul',
'max',
'min',
- 'ceil',
'floor',
+ 'ceil',
'round',
+ 'randInt',
+
+ 'until',
+ 'untilStep',
+ 'seq',
+
+ 'addf',
+ 'add1f',
+ 'subf',
+ 'divf',
+ 'mulf',
+ 'maxf',
+ 'minf',
- // Date functions
'now',
+ 'ago',
'date',
'dateInZone',
'duration',
@@ -105,44 +270,6 @@ const SPRIG_FUNCTIONS = [
'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',
@@ -150,243 +277,805 @@ const SPRIG_FUNCTIONS = [
'toJson',
'toPrettyJson',
'toRawJson',
- 'mustFromJson',
- 'mustToJson',
- 'mustToPrettyJson',
+ 'ternary',
- // Encoding functions
'b64enc',
'b64dec',
- 'b32enc',
- 'b32dec',
+ 'base32enc',
+ 'base32dec',
+
+ 'list',
+ 'first',
+ 'rest',
+ 'last',
+ 'initial',
+ 'append',
+ 'prepend',
+ 'concat',
+ 'reverse',
+ 'uniq',
+ 'without',
+ 'has',
+ 'compact',
+ 'slice',
+
+ 'get',
+ 'set',
+ 'dict',
+ 'hasKey',
+ 'pluck',
+ 'dig',
+ 'deepCopy',
+ 'keys',
+ 'pick',
+ 'omit',
+ 'merge',
+ 'mergeOverwrite',
+ 'values',
+
+ 'atoi',
+ 'int',
+ 'int64',
+ 'float64',
+ 'toDecimal',
+ 'toString',
+ 'toStrings',
+
+ 'regexMatch',
+ 'mustRegexMatch',
+ 'regexFindAll',
+ 'mustRegexFindAll',
+ 'regexFind',
+ 'mustRegexFind',
+ 'regexReplaceAll',
+ 'mustRegexReplaceAll',
+ 'regexReplaceAllLiteral',
+ 'mustRegexReplaceAllLiteral',
+ 'regexSplit',
+ 'mustRegexSplit',
+ 'regexQuoteMeta',
+
+ 'sha1sum',
+ 'sha256sum',
+ 'adler32sum',
+ 'htpasswd',
+ 'derivePassword',
+ 'buildCustomCert',
+ 'genCA',
+ 'genCAWithKey',
+ 'genSelfSignedCert',
+ 'genSelfSignedCertWithKey',
+ 'genSignedCert',
+ 'genSignedCertWithKey',
+ 'encryptAES',
+ 'decryptAES',
+ 'genPrivateKey',
+ 'genPublicKey',
+
+ 'base',
+ 'dir',
+ 'ext',
+ 'clean',
+ 'isAbs',
+ 'osBase',
+ 'osDir',
+ 'osExt',
+ 'osClean',
+ 'osIsAbs',
- // Flow control
'fail',
- 'required',
- 'tpl',
- // UUID functions
'uuidv4',
- // OS functions
'env',
'expandenv',
+
+ 'semver',
+ 'semverCompare',
+
+ 'typeOf',
+ 'typeIs',
+ 'typeIsLike',
+ 'kindOf',
+ 'kindIs',
+ 'deepEqual',
+
+ 'getHostByName',
+
+ 'urlParse',
+ 'urlJoin',
+ 'urlquery',
];
export function GoTemplateEditor({ schema, enableSprig = true, ...props }: GoTemplateEditorProps) {
- const completionItems = useMemo(() => {
- const items = [];
+ const providersRef = useRef<{
+ completionProvider?: monaco.IDisposable;
+ semanticTokensProvider?: monaco.IDisposable;
+ }>({});
- // Add Go template keywords
- items.push(
- ...GO_TEMPLATE_KEYWORDS.map((keyword) => ({
- label: keyword,
- kind: 14,
- insertText: keyword,
- documentation: `Go template keyword: ${keyword}`,
- })),
- );
+ const cleanup = () => {
+ if (providersRef.current.completionProvider) {
+ providersRef.current.completionProvider.dispose();
+ }
+ if (providersRef.current.semanticTokensProvider) {
+ providersRef.current.semanticTokensProvider.dispose();
+ }
+ providersRef.current = {};
+ };
- // Add Sprig functions if enabled
- if (enableSprig) {
- items.push(
- ...SPRIG_FUNCTIONS.map((func) => ({
- label: func,
- kind: 3, // Function
- insertText: `${func} `,
- documentation: `Sprig function: ${func}`,
- })),
+ useEffect(() => {
+ return cleanup;
+ }, []);
+
+ const generateSmartCompletions = (
+ schema: Record | Record | undefined,
+ activeRangeField?: string | null,
+ rangeVariable?: string | null,
+ ): CompletionItem[] => {
+ if (!schema) return [];
+
+ const isSchemaProperty = (obj: unknown): obj is SchemaProperty => {
+ return obj !== null && typeof obj === 'object' && 'type' in obj;
+ };
+
+ if (activeRangeField) {
+ const rangeCompletions = getRangeCompletions(
+ schema,
+ activeRangeField,
+ rangeVariable || null,
+ isSchemaProperty,
);
+
+ const hasAlias = rangeVariable !== null;
+
+ if (hasAlias) {
+ const rootCompletions = getRootCompletions(schema, isSchemaProperty, true);
+ return [...rangeCompletions, ...rootCompletions];
+ } else {
+ return rangeCompletions;
+ }
}
- // Add schema field completion
- if (schema && typeof schema === 'object') {
- const addSchemaFields = (schemaObj: Record, prefix = '.') => {
- // Handle JSON Schema properties
- if (schemaObj.properties && typeof schemaObj.properties === 'object') {
- const properties = schemaObj.properties as Record;
- Object.keys(properties).forEach((key) => {
- const property = properties[key] as Record;
- const fullPath = prefix === '.' ? `.${key}` : `${prefix}.${key}`;
- const type = property.type || 'unknown';
+ return getRootCompletions(schema, isSchemaProperty);
+ };
- items.push({
- label: fullPath,
- kind: 10, // Property
- insertText: fullPath,
- documentation: `Schema field: ${fullPath} (${type})${property.description ? ` - ${property.description}` : ''}`,
- });
+ const getRootCompletions = (
+ schema: Record,
+ isSchemaProperty: (obj: unknown) => obj is SchemaProperty,
+ isInRangeContext = false,
+ ): CompletionItem[] => {
+ const items: CompletionItem[] = [];
+ const sortPrefix = isInRangeContext ? SORT_PREFIXES.ROOT_IN_RANGE : SORT_PREFIXES.ROOT_FIELD;
- // Recursively add nested object properties
- if (property.type === 'object' && property.properties) {
- addSchemaFields(property, fullPath);
- }
- });
+ Object.keys(schema).forEach((key) => {
+ const value = schema[key];
+
+ if (isSchemaProperty(value)) {
+ const prop = value as SchemaProperty;
+ addFieldCompletions(items, key, prop, '', false, sortPrefix);
+ } else {
+ items.push({
+ label: `.${key}`,
+ kind: COMPLETION_KINDS.PROPERTY,
+ insertText: `.${key}`,
+ documentation: `${isInRangeContext ? '(root) ' : ''}Field: ${key}`,
+ sortText: `${sortPrefix}${key}`,
+ });
+ }
+ });
+
+ return items;
+ };
+
+ const getRangeCompletions = (
+ schema: Record,
+ rangeField: string,
+ rangeVariable: string | null,
+ isSchemaProperty: (obj: unknown) => obj is SchemaProperty,
+ ): CompletionItem[] => {
+ const items: CompletionItem[] = [];
+ if (rangeVariable) {
+ items.push({
+ label: rangeVariable,
+ kind: COMPLETION_KINDS.VARIABLE,
+ insertText: rangeVariable,
+ documentation: `Range variable for current item in ${rangeField}`,
+ sortText: `${SORT_PREFIXES.RANGE_VAR}${rangeVariable}`,
+ });
+ }
+
+ const fieldPath = rangeField.startsWith('.') ? rangeField.slice(1) : rangeField;
+ const pathParts = fieldPath.split('.');
+
+ let currentSchema: Record | SchemaProperty | null = schema;
+ for (const part of pathParts) {
+ if (currentSchema && typeof currentSchema === 'object' && !isSchemaProperty(currentSchema)) {
+ const schemaObj = currentSchema as Record;
+ if (schemaObj[part]) {
+ currentSchema = schemaObj[part] as Record | SchemaProperty;
+ if (
+ isSchemaProperty(currentSchema) &&
+ currentSchema.type === 'object' &&
+ currentSchema.properties
+ ) {
+ currentSchema = currentSchema.properties;
+ }
+ } else {
+ return items;
}
- // Handle direct object structure (non-JSON Schema format)
- else {
- Object.keys(schemaObj).forEach((key) => {
- if (key === 'properties' || key === 'type' || key === 'description') return;
+ } else {
+ return items;
+ }
+ }
- const value = schemaObj[key];
- const fullPath = prefix === '.' ? `.${key}` : `${prefix}.${key}`;
+ if (isSchemaProperty(currentSchema) && currentSchema.type === 'array' && currentSchema.items) {
+ const itemSchema = currentSchema.items;
+ if (itemSchema.type === 'object' && itemSchema.properties) {
+ Object.keys(itemSchema.properties).forEach((key) => {
+ const prop = itemSchema.properties![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, fullPath);
+ if (isSchemaProperty(prop)) {
+ if (!rangeVariable) {
+ addFieldCompletions(items, key, prop, '', true);
}
- });
- }
- };
+ } else {
+ if (!rangeVariable) {
+ items.push({
+ label: `.${key}`,
+ kind: COMPLETION_KINDS.PROPERTY,
+ insertText: `.${key}`,
+ documentation: `Field in current item: ${key}`,
+ sortText: `${SORT_PREFIXES.CURRENT}${key}`,
+ });
+ }
+ }
- addSchemaFields(schema);
+ if (rangeVariable) {
+ if (isSchemaProperty(prop)) {
+ items.push({
+ label: `${rangeVariable}.${key}`,
+ kind: COMPLETION_KINDS.PROPERTY,
+ insertText: `${rangeVariable}.${key}`,
+ documentation: `${prop.description || key} (${prop.type}) via range variable`,
+ sortText: `${SORT_PREFIXES.VAR_FIELD}${rangeVariable}_${key}`,
+ });
+ } else {
+ items.push({
+ label: `${rangeVariable}.${key}`,
+ kind: COMPLETION_KINDS.PROPERTY,
+ insertText: `${rangeVariable}.${key}`,
+ documentation: `Field ${key} via range variable ${rangeVariable}`,
+ sortText: `${SORT_PREFIXES.VAR_FIELD}${rangeVariable}_${key}`,
+ });
+ }
+ }
+ });
+ }
}
return items;
- }, [schema, enableSprig]);
+ };
- const handleEditorMount = (editor: unknown, monaco: Monaco) => {
- // Register custom Go template language
- monaco.languages.register({ id: 'gotemplate' });
+ const addFieldCompletions = (
+ items: CompletionItem[],
+ key: string,
+ prop: SchemaProperty,
+ prefix: string,
+ isRangeContext = false,
+ customSortPrefix?: string,
+ ) => {
+ const fieldPath = prefix ? `${prefix}.${key}` : `.${key}`;
+ const contextPrefix = isRangeContext ? '(current item) ' : '';
+ const sortPrefix =
+ customSortPrefix || (isRangeContext ? SORT_PREFIXES.CURRENT : SORT_PREFIXES.ROOT_FIELD);
- // Set syntax highlighting
- monaco.languages.setMonarchTokensProvider('gotemplate', {
+ items.push({
+ label: fieldPath,
+ kind: COMPLETION_KINDS.PROPERTY,
+ insertText: fieldPath,
+ documentation: `${contextPrefix}${prop.description || key} (${prop.type})`,
+ sortText: `${sortPrefix}${fieldPath}`,
+ });
+
+ if (prop.type === 'array' && !isRangeContext) {
+ items.push({
+ label: `range ${fieldPath}`,
+ kind: COMPLETION_KINDS.KEYWORD,
+ insertText: `range ${fieldPath}`,
+ documentation: `Loop through ${fieldPath} array`,
+ sortText: `${SORT_PREFIXES.RANGE_OP}${fieldPath}`,
+ });
+ }
+
+ if (prop.type === 'object' && !isRangeContext) {
+ items.push({
+ label: `with ${fieldPath}`,
+ kind: COMPLETION_KINDS.KEYWORD,
+ insertText: `with ${fieldPath}`,
+ documentation: `Set context to ${fieldPath} object`,
+ sortText: `${SORT_PREFIXES.WITH_OP}${fieldPath}`,
+ });
+ }
+ };
+
+ const getNestedFieldCompletions = (
+ schema: Record | Record | undefined,
+ fieldPath: string,
+ isSchemaProperty: (obj: unknown) => obj is SchemaProperty,
+ ): CompletionItem[] => {
+ if (!schema) return [];
+
+ const items: CompletionItem[] = [];
+ let cleanPath = fieldPath;
+
+ if (cleanPath.startsWith('$')) {
+ cleanPath = cleanPath.replace(/^\$\w+\./, '');
+ } else if (cleanPath.startsWith('.')) {
+ cleanPath = cleanPath.slice(1);
+ }
+
+ if (!cleanPath || cleanPath === '') {
+ Object.keys(schema).forEach((key) => {
+ const value = schema[key];
+ if (isSchemaProperty(value)) {
+ items.push({
+ label: key,
+ kind: 10,
+ insertText: key,
+ documentation: `${value.description || key} (${value.type})`,
+ sortText: `a_nested_${key}`,
+ });
+ } else {
+ items.push({
+ label: key,
+ kind: 10,
+ insertText: key,
+ documentation: `Field: ${key}`,
+ sortText: `a_nested_${key}`,
+ });
+ }
+ });
+ return items;
+ }
+
+ const pathParts = cleanPath.split('.').filter((part) => part && part.length > 0);
+
+ let currentSchema: Record | SchemaProperty | null = schema;
+
+ for (const part of pathParts) {
+ if (currentSchema && typeof currentSchema === 'object' && !isSchemaProperty(currentSchema)) {
+ const schemaObj = currentSchema as Record;
+ if (schemaObj[part]) {
+ currentSchema = schemaObj[part] as Record | SchemaProperty;
+ } else {
+ return items;
+ }
+ } else if (isSchemaProperty(currentSchema)) {
+ if (currentSchema.type === 'object' && currentSchema.properties) {
+ const prop = currentSchema.properties[part] as
+ | SchemaProperty
+ | Record
+ | undefined;
+ if (prop) {
+ currentSchema = prop;
+ } else {
+ return items;
+ }
+ } else {
+ return items;
+ }
+ } else {
+ return items;
+ }
+ }
+
+ if (
+ isSchemaProperty(currentSchema) &&
+ currentSchema.type === 'object' &&
+ currentSchema.properties
+ ) {
+ Object.keys(currentSchema.properties).forEach((key) => {
+ const prop = currentSchema.properties![key];
+ if (isSchemaProperty(prop)) {
+ items.push({
+ label: key,
+ kind: COMPLETION_KINDS.PROPERTY,
+ insertText: key,
+ documentation: `${prop.description || key} (${prop.type})`,
+ sortText: `${SORT_PREFIXES.NESTED}${key}`,
+ });
+ } else {
+ items.push({
+ label: key,
+ kind: COMPLETION_KINDS.PROPERTY,
+ insertText: key,
+ documentation: `Field: ${key}`,
+ sortText: `${SORT_PREFIXES.NESTED}${key}`,
+ });
+ }
+ });
+ }
+
+ return items;
+ };
+
+ const handleBeforeMount = (monaco: Monaco) => {
+ cleanup();
+
+ monaco.languages.register({ id: 'go-template' });
+
+ monaco.languages.setMonarchTokensProvider('go-template', {
tokenizer: {
root: [
- // Comments - match {{/*...*/}} first
- [/\{\{\/\*[\s\S]*?\*\/\}\}/, 'comment'],
- // Template tags - enter template state
- [/\{\{-?/, { token: 'template-tag', next: '@template' }],
- // Regular text
- [/[^{]+/, ''],
- [/[{]/, ''],
+ [/\{\{\/\*/, 'comment', '@comment'],
+ [/\{\{/, 'template-tag', '@template'],
+ [/./, '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
+ [/\/\*/, 'comment', '@comment'],
+ [/\}\}/, 'template-tag', '@pop'],
+ [/"([^"\\]|\\.)*$/, 'string.invalid'],
+ [/"/, 'string', '@string'],
+ [
+ /\b(if|else|end|with|range|template|define|block|include|not|and|or|eq|ne|lt|le|gt|ge|len|index|slice|printf|print|println)\b/,
+ 'keyword',
+ ],
+ [/\.[a-zA-Z_][a-zA-Z0-9_]*/, 'variable'],
+ [/\$[a-zA-Z_][a-zA-Z0-9_]*/, 'variable'],
+ [/\d*\.\d+([eE][-+]?\d+)?/, 'number.float'],
+ [/\d+/, 'number'],
[/[|:]/, 'operator'],
- // Whitespace
- [/\s+/, ''],
- // Any other characters in template
- [/./, ''],
+ [/[a-zA-Z_][a-zA-Z0-9_]*/, 'function'],
+ [/\s+/, 'white'],
],
+
+ comment: [
+ [/\*\//, 'comment', '@pop'],
+ [/./, 'comment'],
+ ],
+
string: [
[/[^\\"]+/, 'string'],
[/\\./, 'string.escape'],
[/"/, 'string', '@pop'],
],
- string_single: [
- [/[^\\']+/, 'string'],
- [/\\./, 'string.escape'],
- [/'/, 'string', '@pop'],
- ],
},
});
- // Override the Dracula theme with Go template colors
+ providersRef.current.completionProvider = monaco.languages.registerCompletionItemProvider(
+ 'go-template',
+ {
+ triggerCharacters: ['.', ' '],
+ provideCompletionItems: (model, position) => {
+ try {
+ const textUntilPosition = model.getValueInRange({
+ startLineNumber: position.lineNumber,
+ startColumn: 1,
+ endLineNumber: position.lineNumber,
+ endColumn: position.column,
+ });
+
+ const lastOpenBrace = textUntilPosition.lastIndexOf('{{');
+ const lastCloseBrace = textUntilPosition.lastIndexOf('}}');
+ const insideTemplate = lastOpenBrace > lastCloseBrace && lastOpenBrace !== -1;
+
+ if (!insideTemplate) {
+ return { suggestions: [] };
+ }
+
+ const fullText = model.getValue();
+ const currentPosition = model.getOffsetAt(position);
+ const textBeforePosition = fullText.substring(0, currentPosition);
+
+ const rangeMatches = [...textBeforePosition.matchAll(/\{\{\s*range\s+([^}]+)\s*\}\}/g)];
+ const endMatches = [...textBeforePosition.matchAll(/\{\{\s*end\s*\}\}/g)];
+
+ let activeRangeField: string | null = null;
+ let rangeVariable: string | null = null;
+ if (rangeMatches.length > endMatches.length) {
+ const lastRange = rangeMatches[rangeMatches.length - 1];
+ if (lastRange && lastRange[1]) {
+ const rangeField = lastRange[1].trim();
+
+ const patterns = [REGEX_PATTERNS.RANGE_ASSIGNMENT, REGEX_PATTERNS.FIELD_PATH];
+
+ for (const pattern of patterns) {
+ const match = rangeField.match(pattern);
+ if (match) {
+ if (pattern.source.includes(':=')) {
+ rangeVariable = match[1] || null;
+ activeRangeField = match[2] || null;
+ } else {
+ activeRangeField = match[1] || null;
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ const templateFunctions: CompletionItem[] = GO_TEMPLATE_KEYWORDS.map((keyword) => ({
+ label: keyword,
+ kind: COMPLETION_KINDS.KEYWORD,
+ insertText: keyword,
+ documentation: `Go template keyword: ${keyword}`,
+ sortText: `${SORT_PREFIXES.KEYWORD}${keyword}`,
+ }));
+
+ const snippetCompletions: CompletionItem[] = Object.values(COMPLETION_SNIPPETS);
+
+ const sprigFunctions: CompletionItem[] = enableSprig
+ ? SPRIG_FUNCTIONS.map((fn) => ({
+ label: fn,
+ kind: COMPLETION_KINDS.FUNCTION,
+ insertText: fn,
+ documentation: `Sprig function: ${fn}`,
+ sortText: `${SORT_PREFIXES.SPRIG}${fn}`,
+ }))
+ : [];
+
+ const baseCompletionItems: CompletionItem[] = [
+ ...templateFunctions,
+ ...snippetCompletions,
+ ...sprigFunctions,
+ ];
+ let allCompletions: CompletionItem[] = [...baseCompletionItems];
+
+ if (schema) {
+ const schemaCompletions = generateSmartCompletions(
+ schema,
+ activeRangeField,
+ rangeVariable,
+ );
+ allCompletions = [...allCompletions, ...schemaCompletions];
+ }
+
+ const wordStart = textUntilPosition.lastIndexOf(' ') + 1;
+ const templateStart = textUntilPosition.lastIndexOf('{{') + 2;
+ const actualStart = Math.max(wordStart, templateStart);
+ const currentWord = textUntilPosition.substring(actualStart).trim();
+
+ let dotMatches = currentWord.match(REGEX_PATTERNS.NESTED_DOT);
+ if (!dotMatches) {
+ const beforeCursor = textUntilPosition.substring(Math.max(templateStart, 0));
+ dotMatches =
+ beforeCursor.match(REGEX_PATTERNS.NESTED_DOT) ||
+ beforeCursor.match(REGEX_PATTERNS.NESTED_VAR) ||
+ beforeCursor.match(REGEX_PATTERNS.NESTED_GENERAL);
+ }
+ const isNestedField = dotMatches && textUntilPosition.endsWith('.') && schema;
+
+ const justTypedDot = currentWord.endsWith('.') || textUntilPosition.endsWith('.');
+ const justTypedSpace = textUntilPosition.endsWith(' ');
+ const wordForFiltering = justTypedDot ? currentWord.slice(0, -1) : currentWord;
+
+ if (isNestedField && schema && dotMatches) {
+ const fieldPath = dotMatches[1];
+ if (fieldPath) {
+ const nestedCompletions = getNestedFieldCompletions(
+ schema,
+ fieldPath,
+ (obj: unknown): obj is SchemaProperty => {
+ return obj !== null && typeof obj === 'object' && 'type' in obj;
+ },
+ );
+ allCompletions = [...allCompletions, ...nestedCompletions];
+ }
+ }
+
+ const isVariableOrFieldItem = (item: CompletionItem): boolean => {
+ return (
+ item.label.startsWith('$') ||
+ item.label.startsWith('.') ||
+ item.sortText?.startsWith(SORT_PREFIXES.RANGE_VAR) ||
+ item.sortText?.startsWith(SORT_PREFIXES.VAR_FIELD) ||
+ item.sortText?.startsWith(SORT_PREFIXES.ROOT_FIELD) ||
+ item.sortText?.startsWith(SORT_PREFIXES.CURRENT)
+ );
+ };
+
+ const isRangeContextItem = (item: CompletionItem): boolean => {
+ return (
+ isVariableOrFieldItem(item) ||
+ item.sortText?.startsWith(SORT_PREFIXES.ROOT_IN_RANGE)
+ );
+ };
+
+ const filteredCompletions = allCompletions.filter((item) => {
+ if (isNestedField) {
+ return item.sortText?.startsWith(SORT_PREFIXES.NESTED);
+ }
+
+ if (justTypedDot) {
+ return activeRangeField ? isRangeContextItem(item) : isVariableOrFieldItem(item);
+ }
+
+ if (justTypedSpace) {
+ return true;
+ }
+
+ if (!wordForFiltering) {
+ return true;
+ }
+
+ const label = item.label.toLowerCase();
+ const word = wordForFiltering.toLowerCase();
+
+ if (word.startsWith('$')) {
+ return label.startsWith('$') || label.includes(word);
+ }
+
+ return (
+ label.includes(word) ||
+ label.startsWith(word) ||
+ label.replace(/[^a-zA-Z0-9]/g, '').includes(word.replace(/[^a-zA-Z0-9]/g, ''))
+ );
+ });
+
+ const createUniqueCompletions = (completions: CompletionItem[]): CompletionItem[] => {
+ const seen = new Set();
+ return completions.filter((item) => {
+ const key = `${item.label}:${item.insertText}`;
+ if (seen.has(key)) {
+ return false;
+ }
+ seen.add(key);
+ return true;
+ });
+ };
+
+ const uniqueFilteredCompletions = createUniqueCompletions(filteredCompletions);
+
+ const getCategoryFromSortText = (sortText: string): string => {
+ if (sortText.startsWith(SORT_PREFIXES.RANGE_VAR)) return 'var';
+ if (sortText.startsWith(SORT_PREFIXES.NESTED)) return 'nested';
+ if (sortText.startsWith(SORT_PREFIXES.CURRENT)) return 'current';
+ if (sortText.startsWith(SORT_PREFIXES.VAR_FIELD)) return 'range_var';
+ if (sortText.startsWith(SORT_PREFIXES.ROOT_FIELD)) return 'field';
+ if (sortText.startsWith(SORT_PREFIXES.RANGE_OP)) return 'range_op';
+ if (sortText.startsWith(SORT_PREFIXES.WITH_OP)) return 'with_op';
+ if (sortText.startsWith(SORT_PREFIXES.ROOT_IN_RANGE)) return 'root';
+ if (sortText.startsWith(SORT_PREFIXES.KEYWORD)) return 'keyword';
+ if (sortText.startsWith(SORT_PREFIXES.SPRIG)) return 'sprig';
+ return 'other';
+ };
+
+ const getSearchText = (): string => {
+ if (isNestedField) {
+ return currentWord.split('.').pop() || '';
+ }
+ if (justTypedDot) {
+ const afterDot = currentWord.replace(/.*\./, '');
+ return afterDot === '' && activeRangeField ? '.' : afterDot;
+ }
+ if (justTypedSpace) {
+ return '';
+ }
+ if (wordForFiltering.startsWith('.')) {
+ return wordForFiltering.slice(1);
+ }
+ return wordForFiltering;
+ };
+
+ const searchText = getSearchText();
+
+ const dynamicallySortedCompletions = uniqueFilteredCompletions
+ .map((item: CompletionItem) => ({
+ ...item,
+ sortText: generateDynamicSortText(
+ item,
+ searchText,
+ getCategoryFromSortText(item.sortText),
+ ),
+ }))
+ .sort((a: CompletionItem, b: CompletionItem) => a.sortText.localeCompare(b.sortText));
+
+ return {
+ suggestions: dynamicallySortedCompletions.map((item: CompletionItem) => {
+ let insertText = item.insertText;
+ let startColumn = actualStart;
+
+ const templateContent = textUntilPosition.substring(templateStart);
+
+ if (isNestedField) {
+ startColumn = position.column;
+ } else if (justTypedDot && item.insertText.startsWith('.')) {
+ insertText = item.insertText.slice(1);
+ startColumn = position.column;
+ } else if (justTypedDot) {
+ startColumn = position.column;
+ } else if (justTypedSpace) {
+ insertText = `${item.insertText} `;
+ startColumn = position.column;
+ } else {
+ const wordMatch = templateContent.match(REGEX_PATTERNS.WORD_WITH_SPACES);
+ if (wordMatch) {
+ const [, leadingSpaces, currentWordInTemplate] = wordMatch;
+ if (leadingSpaces && currentWordInTemplate) {
+ startColumn =
+ templateStart + templateContent.length - currentWordInTemplate.length;
+ } else {
+ const spaceMatch = templateContent.match(REGEX_PATTERNS.LEADING_SPACES);
+ if (
+ spaceMatch &&
+ spaceMatch[1] &&
+ templateContent.trim() === currentWordInTemplate
+ ) {
+ startColumn = templateStart + spaceMatch[1].length;
+ }
+ }
+ }
+ }
+
+ return {
+ ...item,
+ insertText,
+ range: {
+ startLineNumber: position.lineNumber,
+ endLineNumber: position.lineNumber,
+ startColumn,
+ endColumn: position.column,
+ },
+ };
+ }),
+ };
+ } catch (error) {
+ console.error('Go template completion error:', error);
+ return { suggestions: [] };
+ }
+ },
+ },
+ );
+
+ providersRef.current.semanticTokensProvider =
+ monaco.languages.registerDocumentSemanticTokensProvider('go-template', {
+ getLegend: () => ({
+ tokenTypes: ['variable', 'function', 'keyword', 'string', 'number'],
+ tokenModifiers: [],
+ }),
+ provideDocumentSemanticTokens: (model) => {
+ const tokens: number[] = [];
+ const text = model.getValue();
+ const lines = text.split('\n');
+
+ lines.forEach((line, lineIndex) => {
+ const templateMatches = [...line.matchAll(/\{\{([^}]+)\}\}/g)];
+ templateMatches.forEach((match) => {
+ if (match.index !== undefined && match[1]) {
+ const content = match[1].trim();
+ const startCol = match.index + 2;
+ const length = content.length;
+
+ if (content.startsWith('.') || content.startsWith('$')) {
+ tokens.push(lineIndex, startCol, length, 0, 0);
+ }
+ }
+ });
+ });
+
+ return { data: new Uint32Array(tokens) };
+ },
+ releaseDocumentSemanticTokens: () => {},
+ });
+
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
+ { token: 'template-tag', foreground: 'FFB86C', fontStyle: 'bold' },
+ { token: 'template-keyword', foreground: 'BD93F9', fontStyle: 'bold' },
+ { token: 'template-string', foreground: 'F1FA8C' },
+ { token: 'template-function', foreground: '50FA7B' },
+ { token: 'template-variable', foreground: 'F8F8F2' },
+ { token: 'keyword', foreground: 'FF79C6' },
],
colors: {
+ ...DraculaTheme.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>[0], monaco);
- }
};
return (
@@ -394,9 +1083,9 @@ export function GoTemplateEditor({ schema, enableSprig = true, ...props }: GoTem
title='Go Template Editor'
description={`Go text/template syntax${enableSprig ? ' with Sprig functions' : ''}`}
{...props}
- language='gotemplate'
+ language='go-template'
placeholder='Enter your Go template here...'
- onMount={handleEditorMount}
+ beforeMount={handleBeforeMount}
/>
);
}
diff --git a/packages/ui/src/custom-components/editor/monaco-editor.tsx b/packages/ui/src/custom-components/editor/monaco-editor.tsx
index aabec96..aa78a28 100644
--- a/packages/ui/src/custom-components/editor/monaco-editor.tsx
+++ b/packages/ui/src/custom-components/editor/monaco-editor.tsx
@@ -17,6 +17,7 @@ export interface MonacoEditorProps {
placeholder?: string;
render?: (value?: string) => React.ReactNode;
onMount?: OnMount;
+ beforeMount?: (monaco: Monaco) => void;
language?: string;
className?: string;
}
@@ -39,6 +40,7 @@ export function MonacoEditor({
placeholder = 'Start typing...',
render,
onMount,
+ beforeMount,
language = 'markdown',
className,
}: MonacoEditorProps) {
@@ -154,6 +156,9 @@ export function MonacoEditor({
'editor.background': '#00000000',
},
});
+ if (beforeMount) {
+ beforeMount(monaco);
+ }
}}
/>
{!internalValue?.trim() && placeholder && (