From f53d30ef60d499b57a59339af4049f7249d8835c Mon Sep 17 00:00:00 2001 From: Freddy Montes Date: Fri, 9 Oct 2026 10:40:31 -0600 Subject: [PATCH] refactor(ui): one Velocity grammar for every Monaco editor (#37965) The Velocity Playground's grammar moves to @dotcms/ui and merges with the content editor's HTML-aware one, so the playground, the content editor's code fields and its file editor highlight Velocity the same way. Velocity is coloured in text, tags and attribute values; strings, numbers and operators only inside Velocity expressions, so an apostrophe in prose no longer starts a string. Multi-line #* *# comments are coloured too. The language keeps the velocity id the content editor already stores, and edit-content's own grammar is removed. Co-Authored-By: Claude Opus 5.5 --- .../velocity-monaco-language.ts | 65 ------ .../dot-form-file-editor.component.spec.ts | 5 +- .../dot-form-file-editor.component.ts | 41 +--- ...nt-monaco-editor-control.component.spec.ts | 10 +- ...content-monaco-editor-control.component.ts | 29 +-- ...velocity-playground-page.component.spec.ts | 4 +- .../dot-velocity-playground-page.component.ts | 12 +- .../src/lib/monaco/register-velocity.ts | 129 ------------ core-web/libs/ui/src/index.ts | 1 + .../lib/monaco/velocity-html-tokenizer.ts} | 36 +++- .../src/lib/monaco/velocity-language.spec.ts | 102 ++++++++++ .../ui/src/lib/monaco/velocity-language.ts | 187 ++++++++++++++++++ .../src/lib/monaco-editor.mock.ts | 2 + 13 files changed, 346 insertions(+), 277 deletions(-) delete mode 100644 core-web/libs/edit-content/src/lib/custom-languages/velocity-monaco-language.ts delete mode 100644 core-web/libs/portlets/dot-velocity-playground/src/lib/monaco/register-velocity.ts rename core-web/libs/{edit-content/src/lib/custom-languages/html-monaco-language-base.ts => ui/src/lib/monaco/velocity-html-tokenizer.ts} (80%) create mode 100644 core-web/libs/ui/src/lib/monaco/velocity-language.spec.ts create mode 100644 core-web/libs/ui/src/lib/monaco/velocity-language.ts diff --git a/core-web/libs/edit-content/src/lib/custom-languages/velocity-monaco-language.ts b/core-web/libs/edit-content/src/lib/custom-languages/velocity-monaco-language.ts deleted file mode 100644 index 29a69863f813..000000000000 --- a/core-web/libs/edit-content/src/lib/custom-languages/velocity-monaco-language.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { HTML_BASE_TOKENIZER_ROOT, HTML_BASE_TOKENIZER_STATES } from './html-monaco-language-base'; - -const VELOCITY_TOKENS = [ - // Velocity directives with markers - [/^\*#.*?#$\*$/, 'code.velocity'], // Marked directives *#...#* - - // Velocity comments - [/#\*[\s\S]*?\*#/, 'comment.velocity'], // Block comments - [/^\s*##.*/, 'comment.velocity'], // Line comments with optional leading whitespace - - // Velocity directives - [/#(foreach|if|else|elseif|end|set|parse|include|macro|stop)\b/, 'keyword.velocity'], - [/#(dotParse)\b/, 'keyword.dotparse.velocity'], - - // Velocity Variables - [/\$!?\{[^}]+}/, 'variable.velocity'], - [/\$!?[a-zA-Z_][a-zA-Z0-9_]*/, 'variable.velocity'] -] as monaco.languages.IMonarchLanguageRule[]; - -const VELOCITY_STATES = { - velocityVariable: [ - [/\}/, 'variable.velocity.delimiter', '@pop'], - [/\(/, 'delimiter.parenthesis', '@velocityMethod'], - [/[^}()]/, 'variable.velocity'] - ] as monaco.languages.IMonarchLanguageRule[], - - velocityMethod: [ - [/\)/, 'delimiter.parenthesis', '@pop'], - [/\(/, 'delimiter.parenthesis', '@push'], - [/[^()]/, 'variable.velocity'] - ] as monaco.languages.IMonarchLanguageRule[] -}; - -export const dotVelocityLanguageDefinition: monaco.languages.IMonarchLanguage = { - defaultToken: '', - tokenPostfix: '.vtl', - ignoreCase: true, - - brackets: [ - { open: '{', close: '}', token: 'delimiter.curly' }, - { open: '[', close: ']', token: 'delimiter.square' }, - { open: '(', close: ')', token: 'delimiter.parenthesis' }, - { open: '<', close: '>', token: 'delimiter.angle' } - ], - - keywords: [ - 'foreach', - 'if', - 'else', - 'elseif', - 'end', - 'set', - 'parse', - 'include', - 'macro', - 'stop', - 'dotParse' - ], - - tokenizer: { - root: [...VELOCITY_TOKENS, ...HTML_BASE_TOKENIZER_ROOT], - ...HTML_BASE_TOKENIZER_STATES, - ...VELOCITY_STATES - } as monaco.languages.IMonarchLanguage['tokenizer'] -}; diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.spec.ts index 18ca65b871ce..5e94773a1d9a 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.spec.ts @@ -15,14 +15,11 @@ import { DotFormFileEditorComponent } from './dot-form-file-editor.component'; import { DotFileFieldUploadService } from '../../services/upload-file/upload-file.service'; -// monacoMock doesn't expose `getLanguages`, which getInfoByLang / the velocity -// registration call. Provide a no-op so the component's Monaco hooks don't throw. -// It also doesn't expose `MarkerSeverity`, which #hasErrorSeverityMarker relies on to +// monacoMock doesn't expose `MarkerSeverity`, which #hasErrorSeverityMarker relies on to // tell a real syntax error apart from an informational hint/warning marker. // eslint-disable-next-line @typescript-eslint/no-explicit-any (global as any).monaco = { ...monacoMock, - languages: { ...monacoMock.languages, getLanguages: () => [] }, MarkerSeverity: { Hint: 1, Info: 2, Warning: 4, Error: 8 } }; diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.ts index eb0b68f335d1..9375f2e61f5d 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.ts @@ -25,13 +25,12 @@ import { debounceTime, distinctUntilChanged, filter } from 'rxjs/operators'; import { DotMessagePipe, DotFieldValidationMessageComponent, - DotFieldRequiredDirective + DotFieldRequiredDirective, + ensureDotVelocityLanguageRegistered } from '@dotcms/ui'; import { FormFileEditorStore } from './store/form-file-editor.store'; -import { dotVelocityLanguageDefinition } from '../../../../custom-languages/velocity-monaco-language'; -import { AvailableLanguageMonaco } from '../../../../models/dot-edit-content-field.constant'; import { UPLOAD_TYPE, UploadedFile } from '../../../../models/dot-edit-content-file.model'; type DialogProps = { @@ -427,10 +426,9 @@ export class DotFormFileEditorComponent implements OnInit { onEditorInit(editor: monaco.editor.IStandaloneCodeEditor) { this.#editorRef = editor; - // Monaco is now loaded. Register the custom Velocity language so .vtl files get - // proper highlighting (its Monarch tokens — keyword.velocity, variable.velocity, - // … — are coloured by the default `vs` theme). - this.#registerVelocityLanguage(); + // Monaco is now loaded. Register the shared Velocity language so .vtl files + // highlight the same way here as in every other dotCMS editor. + ensureDotVelocityLanguageRegistered(); // initLoad ran in ngOnInit before Monaco's language registry existed, so the // detected language fell back to 'text'. Re-detect it (also fixes the upload @@ -446,33 +444,4 @@ export class DotFormFileEditorComponent implements OnInit { monaco.editor.setModelLanguage(model, this.store.file().language); } } - - /** - * Registers the custom Velocity Monarch language with Monaco, once. Mirrors - * `DotEditContentMonacoEditorControlComponent` so .vtl files highlight the same - * way here. Idempotent: skips registration when another editor already added it. - */ - #registerVelocityLanguage(): void { - if (typeof monaco === 'undefined') { - return; - } - - const alreadyRegistered = monaco.languages - .getLanguages() - .some((lang) => lang.id === AvailableLanguageMonaco.Velocity); - - if (alreadyRegistered) { - return; - } - - monaco.languages.register({ - id: AvailableLanguageMonaco.Velocity, - extensions: ['.vtl'], - mimetypes: ['text/x-velocity'] - }); - monaco.languages.setMonarchTokensProvider( - AvailableLanguageMonaco.Velocity, - dotVelocityLanguageDefinition - ); - } } diff --git a/core-web/libs/edit-content/src/lib/shared/dot-edit-content-monaco-editor-control/dot-edit-content-monaco-editor-control.component.spec.ts b/core-web/libs/edit-content/src/lib/shared/dot-edit-content-monaco-editor-control/dot-edit-content-monaco-editor-control.component.spec.ts index eb01497ced55..158a56926046 100644 --- a/core-web/libs/edit-content/src/lib/shared/dot-edit-content-monaco-editor-control/dot-edit-content-monaco-editor-control.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/shared/dot-edit-content-monaco-editor-control/dot-edit-content-monaco-editor-control.component.spec.ts @@ -127,10 +127,14 @@ describe('DotEditContentMonacoEditorControlComponent', () => { expect(component.$monacoOptions()).toEqual(expectedOptions); }); - it('should register Velocity language when Monaco is loaded', () => { - const registerSpy = vi.spyOn(component, 'registerVelocityLanguage'); + it('should register the shared Velocity grammar under the id this field uses', () => { + const registerSpy = vi.spyOn(monacoMock.languages, 'register'); + spectator.detectChanges(); - expect(registerSpy).toHaveBeenCalled(); + + expect(registerSpy).toHaveBeenCalledWith( + expect.objectContaining({ id: AvailableLanguageMonaco.Velocity }) + ); }); /** diff --git a/core-web/libs/edit-content/src/lib/shared/dot-edit-content-monaco-editor-control/dot-edit-content-monaco-editor-control.component.ts b/core-web/libs/edit-content/src/lib/shared/dot-edit-content-monaco-editor-control/dot-edit-content-monaco-editor-control.component.ts index d59b415ef315..d953e1c1b376 100644 --- a/core-web/libs/edit-content/src/lib/shared/dot-edit-content-monaco-editor-control/dot-edit-content-monaco-editor-control.component.ts +++ b/core-web/libs/edit-content/src/lib/shared/dot-edit-content-monaco-editor-control/dot-edit-content-monaco-editor-control.component.ts @@ -22,8 +22,8 @@ import { ControlContainer, FormControl, ReactiveFormsModule } from '@angular/for import { PaginatorModule } from 'primeng/paginator'; import { DotCMSContentTypeField } from '@dotcms/dotcms-models'; +import { ensureDotVelocityLanguageRegistered } from '@dotcms/ui'; -import { dotVelocityLanguageDefinition } from '../../custom-languages/velocity-monaco-language'; import { isHtml, isJavascript, @@ -37,15 +37,6 @@ import { } from '../../models/dot-edit-content-field.constant'; import { getFieldVariablesParsed, stringToJson } from '../../utils/functions.util'; -interface WindowWithMonaco extends Window { - monaco?: { - languages: { - register: (language: { id: string }) => void; - setMonarchTokensProvider: (id: string, provider: unknown) => void; - }; - }; -} - /** * DotEditContentMonacoEditorControl is an Angular component utilizing Monaco Editor. * It provides a code editing experience with syntax highlighting and advanced features, @@ -234,23 +225,11 @@ export class DotEditContentMonacoEditorControlComponent implements OnDestroy { } /** - * Registers the Velocity language for the Monaco editor. + * Registers the Velocity language for the Monaco editor: the shared grammar from `@dotcms/ui`, + * under the `velocity` id this field's detection and forced language use. */ registerVelocityLanguage() { - this.#ngZone.runOutsideAngular(() => { - const windowWithMonaco = window as WindowWithMonaco; - if (windowWithMonaco.monaco) { - windowWithMonaco.monaco.languages.register({ - id: AvailableLanguageMonaco.Velocity - }); - windowWithMonaco.monaco.languages.setMonarchTokensProvider( - AvailableLanguageMonaco.Velocity, - dotVelocityLanguageDefinition - ); - } else { - console.warn('Monaco is not available globally'); - } - }); + this.#ngZone.runOutsideAngular(() => ensureDotVelocityLanguageRegistered()); } private readonly languageDetectors = { diff --git a/core-web/libs/portlets/dot-velocity-playground/src/lib/dot-velocity-playground-page/dot-velocity-playground-page.component.spec.ts b/core-web/libs/portlets/dot-velocity-playground/src/lib/dot-velocity-playground-page/dot-velocity-playground-page.component.spec.ts index d08df8356b55..b944091448ea 100644 --- a/core-web/libs/portlets/dot-velocity-playground/src/lib/dot-velocity-playground-page/dot-velocity-playground-page.component.spec.ts +++ b/core-web/libs/portlets/dot-velocity-playground/src/lib/dot-velocity-playground-page/dot-velocity-playground-page.component.spec.ts @@ -14,7 +14,7 @@ import { DotMessageService } from '@dotcms/data-access'; import { ComponentStatus } from '@dotcms/dotcms-models'; -import { DotClipboardUtil } from '@dotcms/ui'; +import { DOT_VELOCITY_LANGUAGE_ID, DotClipboardUtil } from '@dotcms/ui'; import { DotVelocityPlaygroundPageComponent } from './dot-velocity-playground-page.component'; import { DotVelocityPlaygroundStore } from './store/dot-velocity-playground.store'; @@ -225,7 +225,7 @@ describe('DotVelocityPlaygroundPageComponent', () => { describe('editor options computed signal', () => { it('exposes velocity language and wrap=on when wrapCode is true', () => { setup({ wrapCode: vi.fn().mockReturnValue(true) }); - expect(spectator.component.$editorOptions().language).toBe('velocity-playground'); + expect(spectator.component.$editorOptions().language).toBe(DOT_VELOCITY_LANGUAGE_ID); expect(spectator.component.$editorOptions().wordWrap).toBe('on'); }); diff --git a/core-web/libs/portlets/dot-velocity-playground/src/lib/dot-velocity-playground-page/dot-velocity-playground-page.component.ts b/core-web/libs/portlets/dot-velocity-playground/src/lib/dot-velocity-playground-page/dot-velocity-playground-page.component.ts index 7e3acdd8b134..73677c0f9653 100644 --- a/core-web/libs/portlets/dot-velocity-playground/src/lib/dot-velocity-playground-page/dot-velocity-playground-page.component.ts +++ b/core-web/libs/portlets/dot-velocity-playground/src/lib/dot-velocity-playground-page/dot-velocity-playground-page.component.ts @@ -30,11 +30,13 @@ import { ComponentStatus } from '@dotcms/dotcms-models'; import { DOT_MONACO_BASE_OPTIONS, DOT_MONACO_RAW_OPTIONS, + DOT_VELOCITY_LANGUAGE_ID, DotClipboardUtil, DotEmptyContainerComponent, DotMessagePipe, DotMonacoRunShortcutEditor, DotSpinnerComponent, + ensureDotVelocityLanguageRegistered, getDotMonacoRunShortcutLabel, PrincipalConfiguration, registerDotMonacoRunShortcut @@ -51,10 +53,6 @@ import { getDownloadParams, VELOCITY_HELP_EXAMPLES } from '../dot-velocity-playground.utils'; -import { - ensureVelocityLanguageRegistered, - VELOCITY_LANGUAGE_ID -} from '../monaco/register-velocity'; @Component({ selector: 'dot-velocity-playground-page', @@ -104,7 +102,7 @@ export class DotVelocityPlaygroundPageComponent { // 3. Computed signals — $ prefix readonly $editorOptions = computed(() => ({ ...DOT_MONACO_BASE_OPTIONS, - language: VELOCITY_LANGUAGE_ID, + language: DOT_VELOCITY_LANGUAGE_ID, wordWrap: this.store.wrapCode() ? 'on' : 'off' })); @@ -182,7 +180,7 @@ export class DotVelocityPlaygroundPageComponent { filter((isLoaded) => isLoaded), take(1) ) - .subscribe(() => ensureVelocityLanguageRegistered()); + .subscribe(() => ensureDotVelocityLanguageRegistered()); } // 6. Public methods @@ -193,7 +191,7 @@ export class DotVelocityPlaygroundPageComponent { * @param editor the Monaco editor instance emitted by `ngx-monaco-editor` */ onEditorInit(editor: DotMonacoRunShortcutEditor): void { - ensureVelocityLanguageRegistered(); + ensureDotVelocityLanguageRegistered(); registerDotMonacoRunShortcut(editor, () => this.onRun(), { label: this.#messageService.get('velocityPlayground.action.run') }); diff --git a/core-web/libs/portlets/dot-velocity-playground/src/lib/monaco/register-velocity.ts b/core-web/libs/portlets/dot-velocity-playground/src/lib/monaco/register-velocity.ts deleted file mode 100644 index a8c20524f811..000000000000 --- a/core-web/libs/portlets/dot-velocity-playground/src/lib/monaco/register-velocity.ts +++ /dev/null @@ -1,129 +0,0 @@ -interface WindowWithMonaco extends Window { - monaco?: { - languages: { - register: (language: { - id: string; - extensions?: string[]; - mimetypes?: string[]; - }) => void; - setMonarchTokensProvider: (id: string, provider: unknown) => void; - getLanguages?: () => Array<{ id: string }>; - }; - }; -} - -export const VELOCITY_LANGUAGE_ID = 'velocity-playground'; - -/** - * Enriched Velocity grammar tuned for the playground. - * - * The grammar in @dotcms/edit-content only emits tokens for `#directive`, - * `$variable`, and comments — everything else (strings, numbers, method calls, - * operators) falls through HTML_BASE_TOKENIZER as an empty token and stays - * uncolored regardless of the active theme. We define a playground-only grammar - * that adds the missing token classes so Monaco's default theme can - * distinguish every category developers expect to see. - */ -const VELOCITY_PLAYGROUND_GRAMMAR = { - defaultToken: '', - tokenPostfix: '.vtl', - ignoreCase: true, - - brackets: [ - { open: '{', close: '}', token: 'delimiter.curly' }, - { open: '[', close: ']', token: 'delimiter.square' }, - { open: '(', close: ')', token: 'delimiter.parenthesis' } - ], - - keywords: [ - 'foreach', - 'if', - 'else', - 'elseif', - 'end', - 'set', - 'parse', - 'include', - 'macro', - 'stop', - 'dotParse' - ], - - tokenizer: { - root: [ - // Block + line comments - [/#\*[\s\S]*?\*#/, 'comment.velocity'], - [/##.*$/, 'comment.velocity'], - - // Velocity directives — match before generic identifiers so `#set` etc. win - [/#dotParse\b/, 'keyword.dotparse.velocity'], - [/#(foreach|if|else|elseif|end|set|parse|include|macro|stop)\b/, 'keyword.velocity'], - - // Velocity variables — `$name`, `${name}`, optional silent `!` - [/\$!?\{[^}]+\}/, 'variable.velocity'], - [/\$!?[a-zA-Z_][a-zA-Z0-9_]*/, 'variable.velocity'], - - // Property / method access — `.name` that follows a variable / call result - [/\.([a-zA-Z_][a-zA-Z0-9_]*)/, 'identifier.method.velocity'], - - // Strings (double, single, triple-double for VTL multi-line) - [/"""/, { token: 'string.velocity', next: '@stringTriple' }], - [/"/, { token: 'string.velocity', next: '@stringDouble' }], - [/'/, { token: 'string.velocity', next: '@stringSingle' }], - - // Numbers - [/\b\d+\.\d+\b/, 'number.float.velocity'], - [/\b\d+\b/, 'number.velocity'], - - // Operators and delimiters - [/==|!=|<=|>=|&&|\|\||[<>]/, 'operator.velocity'], - [/[=+\-*/%]/, 'operator.velocity'], - [/[,:;]/, 'delimiter.velocity'], - [/[{}()[\]]/, '@brackets'] - ], - - stringDouble: [ - [/[^"\\]+/, 'string.velocity'], - [/\\./, 'string.escape.velocity'], - [/"/, { token: 'string.velocity', next: '@pop' }] - ], - - stringSingle: [ - [/[^'\\]+/, 'string.velocity'], - [/\\./, 'string.escape.velocity'], - [/'/, { token: 'string.velocity', next: '@pop' }] - ], - - stringTriple: [ - [/[^"]+/, 'string.velocity'], - [/"""/, { token: 'string.velocity', next: '@pop' }], - [/"/, 'string.velocity'] - ] - } -}; - -let registered = false; - -export const ensureVelocityLanguageRegistered = (): void => { - if (registered) return; - - const win = window as WindowWithMonaco; - const monaco = win.monaco; - if (!monaco) return; - - const knownLanguages = monaco.languages.getLanguages?.() ?? []; - const already = knownLanguages.some((lang) => lang.id === VELOCITY_LANGUAGE_ID); - if (!already) { - monaco.languages.register({ - id: VELOCITY_LANGUAGE_ID, - extensions: ['.vtl'], - mimetypes: ['text/x-velocity'] - }); - monaco.languages.setMonarchTokensProvider( - VELOCITY_LANGUAGE_ID, - VELOCITY_PLAYGROUND_GRAMMAR - ); - } - - registered = true; -}; diff --git a/core-web/libs/ui/src/index.ts b/core-web/libs/ui/src/index.ts index c97a1825020f..3181fecb1cbb 100644 --- a/core-web/libs/ui/src/index.ts +++ b/core-web/libs/ui/src/index.ts @@ -153,6 +153,7 @@ export * from './lib/dialog/fullscreen-dialog'; // Monaco editor presets export * from './lib/monaco/editor-options'; export * from './lib/monaco/run-shortcut'; +export * from './lib/monaco/velocity-language'; // Theme export * from './lib/theme'; diff --git a/core-web/libs/edit-content/src/lib/custom-languages/html-monaco-language-base.ts b/core-web/libs/ui/src/lib/monaco/velocity-html-tokenizer.ts similarity index 80% rename from core-web/libs/edit-content/src/lib/custom-languages/html-monaco-language-base.ts rename to core-web/libs/ui/src/lib/monaco/velocity-html-tokenizer.ts index caa7baa3ab89..7f1c4347d7c2 100644 --- a/core-web/libs/edit-content/src/lib/custom-languages/html-monaco-language-base.ts +++ b/core-web/libs/ui/src/lib/monaco/velocity-html-tokenizer.ts @@ -1,4 +1,12 @@ -export const HTML_BASE_TOKENIZER_ROOT = [ +/** + * The markup half of the Velocity grammar: Monaco's HTML Monarch rules, with `