From 1da9702704122e33b1d75a22d4767401a3fc9d48 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Wed, 23 Oct 2024 16:01:07 -0400 Subject: [PATCH] feat(edit-content) add language variable to wysiwyg and autodetect language code (#30419) ### Proposed Changes * Add support to WYSIWYG to add Language Variables * Auto-detect language in code. * Add Velocity to code detection ### Checklist - [x] Tests - [ ] Translations - [ ] Security Implications Contemplated (add notes if applicable) ### Additional Info ** any additional useful context or info ** ### Screenshots https://github.com/user-attachments/assets/3f8ac8bd-e89b-471f-a669-e044b675154a --- .../dot-languages.service.spec.ts | 5 + .../dot-languages/dot-languages.service.ts | 24 +++ ...t-edit-content-category-field.component.ts | 1 + .../velocity-monaco-language.ts | 136 ++++++++++++ .../dot-wysiwyg-monaco.component.html | 2 +- .../dot-wysiwyg-monaco.component.scss | 14 +- .../dot-wysiwyg-monaco.component.spec.ts | 35 +-- .../dot-wysiwyg-monaco.component.ts | 174 +++++++++++++-- .../dot-wysiwyg-tinymce.component.ts | 12 +- ...-edit-content-wysiwyg-field.component.html | 41 ++-- ...-edit-content-wysiwyg-field.component.scss | 30 ++- ...it-content-wysiwyg-field.component.spec.ts | 142 ++++++------- ...ot-edit-content-wysiwyg-field.component.ts | 199 ++++++++++++++---- ...dot-edit-content-wysiwyg-field.constant.ts | 53 ++++- ...t-edit-content-wysiwyg-field.utils.spec.ts | 141 +++++++++---- .../dot-edit-content-wysiwyg-field.utils.ts | 70 +++++- .../src/lib/monaco-editor.mock.ts | 3 +- .../WEB-INF/messages/Language.properties | 7 +- 18 files changed, 856 insertions(+), 233 deletions(-) create mode 100644 core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-monaco/custom-languages/velocity-monaco-language.ts diff --git a/core-web/libs/data-access/src/lib/dot-languages/dot-languages.service.spec.ts b/core-web/libs/data-access/src/lib/dot-languages/dot-languages.service.spec.ts index 0d938719cac3..889389e4e004 100644 --- a/core-web/libs/data-access/src/lib/dot-languages/dot-languages.service.spec.ts +++ b/core-web/libs/data-access/src/lib/dot-languages/dot-languages.service.spec.ts @@ -81,4 +81,9 @@ describe('DotLanguagesService', () => { spectator.service.getByISOCode('test').subscribe(); spectator.expectOne(`${LANGUAGE_API_URL}/test`, HttpMethod.GET); }); + + it('should get language variables', () => { + spectator.service.getLanguageVariables().subscribe(); + spectator.expectOne(`${LANGUAGE_API_URL}/variables`, HttpMethod.GET); + }); }); diff --git a/core-web/libs/data-access/src/lib/dot-languages/dot-languages.service.ts b/core-web/libs/data-access/src/lib/dot-languages/dot-languages.service.ts index 4c9cd5e0d8d2..0dad932f9a0d 100644 --- a/core-web/libs/data-access/src/lib/dot-languages/dot-languages.service.ts +++ b/core-web/libs/data-access/src/lib/dot-languages/dot-languages.service.ts @@ -5,12 +5,25 @@ import { inject, Injectable } from '@angular/core'; import { pluck } from 'rxjs/operators'; +import { DotCMSResponse } from '@dotcms/dotcms-js'; import { DotAddLanguage, DotLanguage, DotLanguagesISO } from '@dotcms/dotcms-models'; export const LANGUAGE_API_URL = '/api/v2/languages'; export const LANGUAGE_API_URL_WITH_VARS = '/api/v2/languages?countLangVars=true'; +export interface DotLanguageVariables { + total: number; + variables: Record; +} + +export interface DotLanguageVariableEntry { + [languageCode: string]: { + identifier: string; + value: string; + }; +} + /** * Provide util methods to get Languages available in the system. * @export @@ -114,4 +127,15 @@ export class DotLanguagesService { getISO(): Observable { return this.httpClient.get(`${LANGUAGE_API_URL}/iso`).pipe(pluck('entity')); } + + /** + * Get language variables. + * + * @returns {Observable>} An observable of the language variables. + */ + getLanguageVariables(): Observable> { + return this.httpClient + .get>(`${LANGUAGE_API_URL}/variables`) + .pipe(pluck('entity', 'variables')); + } } diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/dot-edit-content-category-field.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/dot-edit-content-category-field.component.ts index 70148fa4e724..5628a000baf8 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/dot-edit-content-category-field.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/dot-edit-content-category-field.component.ts @@ -76,6 +76,7 @@ export class DotEditContentCategoryFieldComponent implements OnInit { * @returns {Boolean} - True if there are selected categories, false otherwise. */ $hasSelectedCategories = computed(() => !!this.store.selected()); + /** * Getter to retrieve the category field control. * diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-monaco/custom-languages/velocity-monaco-language.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-monaco/custom-languages/velocity-monaco-language.ts new file mode 100644 index 000000000000..98471448fcba --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-monaco/custom-languages/velocity-monaco-language.ts @@ -0,0 +1,136 @@ +// Velocity language definition for Monaco Editor +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: [ + // HTML Comments + [//, 'comment.html', '@pop'], + [/-/, 'comment.html'] + ], + + htmlAttributeValue: [ + [/[^"]+/, 'string.html'], + [ + /(\$!?\{?)([a-zA-Z][\w-]*(?:\.[a-zA-Z][\w-]*)*(?:\([^)]*\))?)(\})?/, + ['variable.velocity', 'variable.velocity', 'variable.velocity'] + ], + [/\$[a-zA-Z][\w-]*/, 'variable.velocity'], + [/"/, { token: 'string.html', next: '@pop' }] + ], + + string_double: [ + [/[^\\"$]+/, 'string.velocity'], + [/\\./, 'string.escape.velocity'], + [ + /(\$!?\{?)([a-zA-Z][\w-]*(?:\.[a-zA-Z][\w-]*)*(?:\([^)]*\))?)(\})?/, + ['variable.velocity', 'variable.velocity', 'variable.velocity'] + ], + [/\$[a-zA-Z][\w-]*/, 'variable.velocity'], + [/"/, 'string.velocity', '@pop'] + ], + + string_single: [ + [/[^\\'$]+/, 'string.velocity'], + [/\\./, 'string.escape.velocity'], + [ + /(\$!?\{?)([a-zA-Z][\w-]*(?:\.[a-zA-Z][\w-]*)*(?:\([^)]*\))?)(\})?/, + ['variable.velocity', 'variable.velocity', 'variable.velocity'] + ], + [/\$[a-zA-Z][\w-]*/, 'variable.velocity'], + [/'/, 'string.velocity', '@pop'] + ], + + velocityComment: [ + [/[^*#]+/, 'comment.velocity'], + [/#\*/, 'comment.velocity', '@push'], + [/\*#/, 'comment.velocity', '@pop'], + [/[*#]/, 'comment.velocity'] + ], + + velocityVariable: [ + [/\}/, 'variable.velocity.delimiter', '@pop'], + [/\(/, 'delimiter.parenthesis', '@velocityMethod'], + [/[^}()]/, 'variable.velocity'] + ], + + velocityMethod: [ + [/\)/, 'delimiter.parenthesis', '@pop'], + [/\(/, 'delimiter.parenthesis', '@push'], + [/[^()]/, 'variable.velocity'] + ] + } +}; diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-monaco/dot-wysiwyg-monaco.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-monaco/dot-wysiwyg-monaco.component.html index bc37b961960f..69b9cb5797d3 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-monaco/dot-wysiwyg-monaco.component.html +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-monaco/dot-wysiwyg-monaco.component.html @@ -1,5 +1,5 @@ { let spectator: Spectator; + let component: DotWysiwygMonacoComponent; const createComponent = createComponentFactory({ component: DotWysiwygMonacoComponent, @@ -37,24 +38,27 @@ describe('DotWysiwygMonacoComponent', () => { field: WYSIWYG_MOCK } as unknown }); - }); - it('should set default language', () => { - expect(spectator.component.$language()).toBe(DEFAULT_MONACO_LANGUAGE); + component = spectator.component; }); - it('should set custom language', () => { - const customLanguage = 'javascript'; - spectator.setInput('language', customLanguage); - expect(spectator.component.$language()).toBe(customLanguage); + it('should set default language', () => { + expect(component.$language()).toBe(DEFAULT_MONACO_LANGUAGE); }); - it('should generate correct Monaco options', () => { + it('should generate correct Monaco options', async () => { const expectedOptions = { ...DEFAULT_WYSIWYG_FIELD_MONACO_CONFIG, - language: DEFAULT_MONACO_LANGUAGE + language: 'plaintext' // due the auto detect language is plaintext }; - expect(spectator.component.$monacoOptions()).toEqual(expectedOptions); + + // Wait for any potential asynchronous operations to complete + await spectator.fixture.whenStable(); + + // Force change detection + spectator.detectChanges(); + + expect(component.$monacoOptions()).toEqual(expectedOptions); }); it('should parse custom props from field variables', () => { @@ -73,8 +77,15 @@ describe('DotWysiwygMonacoComponent', () => { const expectedOptions = { ...DEFAULT_WYSIWYG_FIELD_MONACO_CONFIG, ...customProps, - language: DEFAULT_MONACO_LANGUAGE + language: 'plaintext' // due the auto detect language is plaintext }; - expect(spectator.component.$monacoOptions()).toEqual(expectedOptions); + expect(component.$monacoOptions()).toEqual(expectedOptions); + }); + + it('should register Velocity language when Monaco is loaded', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const registerSpy = jest.spyOn(component as any, 'registerVelocityLanguage'); + component.ngOnInit(); + expect(registerSpy).toHaveBeenCalled(); }); }); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-monaco/dot-wysiwyg-monaco.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-monaco/dot-wysiwyg-monaco.component.ts index e757c8d399af..89b2a9d02d54 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-monaco/dot-wysiwyg-monaco.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-monaco/dot-wysiwyg-monaco.component.ts @@ -1,26 +1,53 @@ -import { MonacoEditorComponent, MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'; +import { + MonacoEditorComponent, + MonacoEditorLoaderService, + MonacoEditorModule +} from '@materia-ui/ngx-monaco-editor'; import { ChangeDetectionStrategy, Component, computed, + DestroyRef, inject, input, + NgZone, OnDestroy, + OnInit, + signal, viewChild } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ControlContainer, ReactiveFormsModule } from '@angular/forms'; import { PaginatorModule } from 'primeng/paginator'; import { DotCMSContentTypeField } from '@dotcms/dotcms-models'; +import { dotVelocityLanguageDefinition } from './custom-languages/velocity-monaco-language'; + import { getFieldVariablesParsed, stringToJson } from '../../../../utils/functions.util'; import { + AvailableLanguageMonaco, COMMENT_TINYMCE, DEFAULT_MONACO_LANGUAGE, DEFAULT_WYSIWYG_FIELD_MONACO_CONFIG } from '../../dot-edit-content-wysiwyg-field.constant'; +import { + isHtml, + isJavascript, + isMarkdown, + isVelocity +} from '../../dot-edit-content-wysiwyg-field.utils'; + +interface WindowWithMonaco extends Window { + monaco?: { + languages: { + register: (language: { id: string }) => void; + setMonarchTokensProvider: (id: string, provider: unknown) => void; + }; + }; +} /** * DotWysiwygMonacoComponent is an Angular component utilizing Monaco Editor. @@ -41,24 +68,21 @@ import { ], changeDetection: ChangeDetectionStrategy.OnPush }) -export class DotWysiwygMonacoComponent implements OnDestroy { +export class DotWysiwygMonacoComponent implements OnDestroy, OnInit { + #monacoLoaderService: MonacoEditorLoaderService = inject(MonacoEditorLoaderService); + #ngZone: NgZone = inject(NgZone); + #destroyRef = inject(DestroyRef); + /** * Holds a reference to the MonacoEditorComponent. */ - $editorRef = viewChild('editorRef'); + $monacoEditorComponentRef = viewChild('editorRef'); /** * Represents a required DotCMS content type field. */ $field = input.required({ alias: 'field' }); - /** - * Represents the programming language to be used in the Monaco editor. - * This variable sets the default language for code input and is initially set to `DEFAULT_MONACO_LANGUAGE`. - * It can be customized by providing a different value through the alias 'language'. - */ - $language = input(DEFAULT_MONACO_LANGUAGE, { alias: 'language' }); - /** * A computed property that retrieves and parses custom Monaco properties that comes from * Field Variable with the name `monacoOptions` @@ -92,6 +116,12 @@ export class DotWysiwygMonacoComponent implements OnDestroy { }; }); + /** + * A signal that holds the current language of the Monaco editor. + * It starts with the default language, which is 'plaintext'. + */ + $language = signal(DEFAULT_MONACO_LANGUAGE); + /** * A disposable reference that manages the lifecycle of content change listeners. * It starts as null and can be assigned a disposable object that will be used @@ -101,29 +131,49 @@ export class DotWysiwygMonacoComponent implements OnDestroy { */ #contentChangeDisposable: monaco.IDisposable | null = null; + ngOnInit() { + this.#monacoLoaderService.isMonacoLoaded$ + .pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe((isLoaded) => { + if (isLoaded) { + this.registerVelocityLanguage(); + } + }); + } + + /** + * Inserts content into the Monaco editor. + * + * @param {string} content - The content to insert into the editor. + */ + insertContent(content: string): void { + if (this.#editor) { + const selection = this.#editor.getSelection(); + const range = new monaco.Range( + selection.startLineNumber, + selection.startColumn, + selection.endLineNumber, + selection.endColumn + ); + const id = { major: 1, minor: 1 }; + const op = { identifier: id, range, text: content, forceMoveMarkers: true }; + this.#editor.executeEdits('my-source', [op]); + } + } + /** * Initializes the editor by setting up the editor reference, * processing the editor content, and setting up a listener for content changes. * * @return {void} No return value. */ - onEditorInit() { - this.#editor = this.$editorRef().editor; + onEditorInit($editorRef: monaco.editor.IStandaloneCodeEditor) { + this.#editor = $editorRef; + this.processEditorContent(); this.setupContentChangeListener(); } - private processEditorContent() { - if (this.#editor) { - const currentContent = this.#editor.getValue(); - - const processedContent = this.removeWysiwygComment(currentContent); - if (currentContent !== processedContent) { - this.#editor.setValue(processedContent); - } - } - } - ngOnDestroy() { try { if (this.#contentChangeDisposable) { @@ -143,17 +193,39 @@ export class DotWysiwygMonacoComponent implements OnDestroy { } } + private processEditorContent() { + if (this.#editor) { + const currentContent = this.#editor.getValue(); + + const processedContent = this.removeWysiwygComment(currentContent); + if (currentContent !== processedContent) { + this.#editor.setValue(processedContent); + } + + this.detectLanguage(); + } + } + private removeEditor() { this.#editor.dispose(); this.#editor = null; } + /** + * Removes the TinyMCE comment from the content. + * + * @param {string} content - The content to remove the comment from. + * @returns {string} The content with the TinyMCE comment removed. + */ private removeWysiwygComment(content: string): string { const regex = new RegExp(`^\\s*${COMMENT_TINYMCE}\\s*`); return content.replace(regex, ''); } + /** + * Sets up a listener for content changes in the Monaco editor. + */ private setupContentChangeListener() { if (this.#editor) { this.#contentChangeDisposable = this.#editor.onDidChangeModelContent(() => { @@ -161,4 +233,60 @@ export class DotWysiwygMonacoComponent implements OnDestroy { }); } } + + /** + * Sets the language of the Monaco editor. + * + * @param {string} language - The language to set for the editor. + */ + private setLanguage(language: string) { + this.$language.set(language); + } + + /** + * Registers the Velocity language for the Monaco editor. + */ + private 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 + ); + console.warn('Velocity language registered successfully'); + } else { + console.warn('Monaco is not available globally'); + } + }); + } + + private readonly languageDetectors = { + [AvailableLanguageMonaco.Velocity]: isVelocity, + [AvailableLanguageMonaco.Javascript]: isJavascript, + [AvailableLanguageMonaco.Html]: isHtml, + [AvailableLanguageMonaco.Markdown]: isMarkdown + }; + + /** + * Detects the language of the content in the Monaco editor and sets the appropriate language. + */ + private detectLanguage() { + const content = this.#editor.getValue().trim(); + + if (!content) { + this.setLanguage(AvailableLanguageMonaco.PlainText); + + return; + } + + const detectedLanguage = Object.entries(this.languageDetectors).find(([, detector]) => + detector(content) + )?.[0] as AvailableLanguageMonaco; + + this.setLanguage(detectedLanguage || AvailableLanguageMonaco.PlainText); + } } diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-tinymce/dot-wysiwyg-tinymce.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-tinymce/dot-wysiwyg-tinymce.component.ts index 7c28c0270ef9..15a9603b0ebc 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-tinymce/dot-wysiwyg-tinymce.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-tinymce/dot-wysiwyg-tinymce.component.ts @@ -77,7 +77,6 @@ export class DotWysiwygTinymceComponent implements OnDestroy { * and custom properties specific to the content field. Additionally, it sets * up the editor with initial plugins using the `dotWysiwygPluginService`. */ - $editorConfig = computed(() => { const config: RawEditorOptions = { ...DEFAULT_TINYMCE_CONFIG, @@ -103,6 +102,17 @@ export class DotWysiwygTinymceComponent implements OnDestroy { return config; }); + /** + * Inserts content into the TinyMCE editor. + * + * @param {string} content - The content to insert into the editor. + */ + insertContent(content: string): void { + if (this.#editor) { + this.#editor.execCommand('mceInsertContent', false, content); + } + } + /** * The #editor variable represents an instance of the Editor class, which provides functionality for text editing. */ diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.html index d507855f5c11..96d22c077ade 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.html +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.html @@ -1,11 +1,4 @@ -
- @if ($displayedEditor() === editorTypes.TinyMCE) { - - } @else { - - } -
-
+
- @if ($selectedEditorDropdown() === editorTypes.Monaco) { - + + [suggestions]="$filteredSuggestions()" + (onSelect)="onSelectLanguageVariable($event)" + (completeMethod)="search($event)" + [styleClass]="'dot-wysiwyg__language-autocomplete--with-icon'"> + + + {{ variable.key }} - {{ variable.value }} + + + + +
+
+ +
+ @if ($displayedEditor() === editorTypes.TinyMCE) { + + } @else { + }
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.scss index 26867d40f9bd..4cc0d6d16af6 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.scss +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.scss @@ -1,21 +1,37 @@ @use "variables" as *; :host { - &.wysiwyg__wrapper { + &.dot-wysiwyg__wrapper { display: flex; gap: $spacing-1; flex-direction: column; } - .wysiwyg__editor { + .dot-wysiwyg__editor { display: flex; flex-direction: column; + border: $field-border-size solid $color-palette-gray-400; + border-radius: $border-radius-md; } - .wysiwyg__controls { + .dot-wysiwyg__controls { display: flex; justify-content: space-between; } + .dot-wysiwyg__language-selector { + position: relative; + min-width: 12rem; + } + + .dot-wysiwyg__search-icon { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: $color-palette-primary-500; + } + ::ng-deep { // Hide the promotion button // This button redirect to the tinyMCE premium page @@ -32,12 +48,14 @@ } .tox-tinymce { - border: $field-border-size solid $color-palette-gray-400; - border-radius: $border-radius-md; + border: none; } p-dropdown { - min-width: 10rem; + min-width: 12rem; + } + p-autocomplete .p-inputwrapper { + padding-right: 35px; } } } diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.spec.ts index 4d9502c059ee..043d3cd9a6a3 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.spec.ts @@ -1,5 +1,11 @@ import { expect } from '@jest/globals'; -import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { + byTestId, + createComponentFactory, + mockProvider, + Spectator, + SpyObject +} from '@ngneat/spectator/jest'; import { of } from 'rxjs'; import { provideHttpClient } from '@angular/common/http'; @@ -11,8 +17,13 @@ import { ConfirmationService } from 'primeng/api'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { DropdownModule } from 'primeng/dropdown'; -import { DotPropertiesService, DotUploadFileService } from '@dotcms/data-access'; -import { mockMatchMedia, monacoMock } from '@dotcms/utils-testing'; +import { + DotLanguagesService, + DotLanguageVariableEntry, + DotPropertiesService, + DotUploadFileService +} from '@dotcms/data-access'; +import { DotMessagePipe, mockMatchMedia, monacoMock } from '@dotcms/utils-testing'; import { DotWysiwygMonacoComponent } from './components/dot-wysiwyg-monaco/dot-wysiwyg-monaco.component'; import { DotWysiwygTinymceComponent } from './components/dot-wysiwyg-tinymce/dot-wysiwyg-tinymce.component'; @@ -20,7 +31,6 @@ import { DotWysiwygTinymceService } from './components/dot-wysiwyg-tinymce/servi import { DotEditContentWYSIWYGFieldComponent } from './dot-edit-content-wysiwyg-field.component'; import { AvailableEditor, - AvailableLanguageMonaco, DEFAULT_EDITOR, EditorOptions } from './dot-edit-content-wysiwyg-field.constant'; @@ -28,13 +38,49 @@ import { DotWysiwygPluginService } from './dot-wysiwyg-plugin/dot-wysiwyg-plugin import { DEFAULT_IMAGE_URL_PATTERN } from './dot-wysiwyg-plugin/utils/editor.utils'; import { WYSIWYG_FIELD_CONTENTLET_MOCK_NO_CONTENT, - WYSIWYG_MOCK, - WYSIWYG_FIELD_CONTENTLET_MOCK_WITH_WYSIWYG_CONTENT, - WYSIWYG_VARIABLE_NAME + WYSIWYG_MOCK } from './mocks/dot-edit-content-wysiwyg-field.mock'; import { createFormGroupDirectiveMock } from '../../utils/mocks'; +const mockLanguageVariables: Record = { + 'ai-text-area-key': { + 'en-us': { + identifier: '034a07f0f308db12d55fa74bb3b265f0', + value: 'AI text area value' + }, + 'es-es': null, + 'es-pa': null + }, + 'com.dotcms.repackage.javax.portlet.title.c-Freddy': { + 'en-us': null, + 'es-es': { + identifier: '175d27eb-9e2c-4fdc-9c4a-0e7d88ce4e87', + value: 'Freddy' + }, + 'es-pa': null + }, + 'com.dotcms.repackage.javax.portlet.title.c-Landing-Pages': { + 'en-us': { + identifier: '06e1f11b-410a-428b-947c-ed60dcc8420d', + value: 'Landing Pages' + }, + 'es-es': { + identifier: '1547f21d-c357-4524-afb0-b728fe3217db', + value: 'Landing Pages' + }, + 'es-pa': null + }, + 'com.dotcms.repackage.javax.portlet.title.c-Personas': { + 'en-us': { + identifier: '1102be5608453fb28485c5f1060f5be3', + value: 'Personas' + }, + 'es-es': null, + es_pa: null + } +}; + const mockScrollIntoView = () => { Element.prototype.scrollIntoView = jest.fn(); }; @@ -46,10 +92,17 @@ const mockSystemWideConfig = { systemWideOption: 'value' }; describe('DotEditContentWYSIWYGFieldComponent', () => { let spectator: Spectator; + let dotLanguagesService: SpyObject; const createComponent = createComponentFactory({ component: DotEditContentWYSIWYGFieldComponent, - imports: [DropdownModule, NoopAnimationsModule, FormsModule, ConfirmDialogModule], + imports: [ + DropdownModule, + NoopAnimationsModule, + FormsModule, + ConfirmDialogModule, + DotMessagePipe + ], componentViewProviders: [ { provide: ControlContainer, @@ -69,6 +122,7 @@ describe('DotEditContentWYSIWYGFieldComponent', () => { }) ], providers: [ + mockProvider(DotLanguagesService), mockProvider(DotUploadFileService), provideHttpClient(), provideHttpClientTesting(), @@ -89,6 +143,10 @@ describe('DotEditContentWYSIWYGFieldComponent', () => { detectChanges: false }); + dotLanguagesService = spectator.inject(DotLanguagesService); + + dotLanguagesService.getLanguageVariables.mockReturnValue(of(mockLanguageVariables)); + spectator.detectChanges(); }); @@ -137,73 +195,9 @@ describe('DotEditContentWYSIWYGFieldComponent', () => { expect(spectator.query(DotWysiwygTinymceComponent)).toBeNull(); expect(spectator.query(DotWysiwygMonacoComponent)).toBeTruthy(); }); - }); - - describe('With Monaco Editor', () => { - beforeEach(() => { - spectator.component.$selectedEditorDropdown.set(AvailableEditor.Monaco); - spectator.detectChanges(); - }); - - it('should has a dropdown for language selection', () => { - expect(spectator.query(byTestId('language-selector'))).toBeTruthy(); - expect(spectator.query(byTestId('editor-selector'))).toBeTruthy(); - }); - - it('should selected `javascript` as selected language', () => { - spectator = createComponent({ - props: { - field: WYSIWYG_MOCK, - contentlet: { - ...WYSIWYG_FIELD_CONTENTLET_MOCK_WITH_WYSIWYG_CONTENT, - [WYSIWYG_VARIABLE_NAME]: 'const a = 5;' - } - } as unknown, - detectChanges: false - }); - spectator.detectChanges(); - - expect(spectator.component.$contentEditorUsed()).toBe(AvailableEditor.Monaco); - expect(spectator.component.$contentLanguageUsed()).toBe( - AvailableLanguageMonaco.Javascript - ); - }); - - it('should selected `markdown` as selected language', () => { - spectator = createComponent({ - props: { - field: WYSIWYG_MOCK, - contentlet: { - ...WYSIWYG_FIELD_CONTENTLET_MOCK_WITH_WYSIWYG_CONTENT, - [WYSIWYG_VARIABLE_NAME]: `# Main title - ## Level 2 title` - } - } as unknown, - detectChanges: false - }); - spectator.detectChanges(); - - expect(spectator.component.$contentEditorUsed()).toBe(AvailableEditor.Monaco); - expect(spectator.component.$contentLanguageUsed()).toBe( - AvailableLanguageMonaco.Markdown - ); - }); - - it('should selected `html` as selected language', () => { - spectator = createComponent({ - props: { - field: WYSIWYG_MOCK, - contentlet: { - ...WYSIWYG_FIELD_CONTENTLET_MOCK_WITH_WYSIWYG_CONTENT, - [WYSIWYG_VARIABLE_NAME]: `

Title

content

` - } - } as unknown, - detectChanges: false - }); - spectator.detectChanges(); - expect(spectator.component.$contentEditorUsed()).toBe(AvailableEditor.Monaco); - expect(spectator.component.$contentLanguageUsed()).toBe(AvailableLanguageMonaco.Html); + it('should render language variable selector', () => { + expect(spectator.query(byTestId('language-variable-selector'))).toBeTruthy(); }); }); }); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.ts index f9f17eeabb87..bc76e9127399 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.ts @@ -8,32 +8,44 @@ import { inject, input, model, - signal + Signal, + signal, + viewChild } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ConfirmationService } from 'primeng/api'; +import { + AutoCompleteCompleteEvent, + AutoCompleteModule, + AutoCompleteSelectEvent +} from 'primeng/autocomplete'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { DropdownModule } from 'primeng/dropdown'; +import { InputGroupModule } from 'primeng/inputgroup'; +import { InputGroupAddonModule } from 'primeng/inputgroupaddon'; +import { TooltipModule } from 'primeng/tooltip'; + +import { take } from 'rxjs/operators'; -import { DotMessageService } from '@dotcms/data-access'; +import { DotLanguagesService, DotMessageService } from '@dotcms/data-access'; import { DotCMSContentlet, DotCMSContentTypeField } from '@dotcms/dotcms-models'; +import { DotMessagePipe } from '@dotcms/ui'; import { DotWysiwygMonacoComponent } from './components/dot-wysiwyg-monaco/dot-wysiwyg-monaco.component'; import { DotWysiwygTinymceComponent } from './components/dot-wysiwyg-tinymce/dot-wysiwyg-tinymce.component'; import { AvailableEditor, - AvailableLanguageMonaco, COMMENT_TINYMCE, DEFAULT_EDITOR, - DEFAULT_MONACO_LANGUAGE, - EditorOptions, - HtmlTags, - JsKeywords, - MdSyntax, - MonacoLanguageOptions + EditorOptions } from './dot-edit-content-wysiwyg-field.constant'; -import { CountOccurrences, shouldUseDefaultEditor } from './dot-edit-content-wysiwyg-field.utils'; +import { shouldUseDefaultEditor } from './dot-edit-content-wysiwyg-field.utils'; + +interface LanguageVariable { + key: string; + value: string; +} /** * Component representing a WYSIWYG (What You See Is What You Get) editor field for editing content in DotCMS. @@ -48,18 +60,30 @@ import { CountOccurrences, shouldUseDefaultEditor } from './dot-edit-content-wys DotWysiwygTinymceComponent, DotWysiwygMonacoComponent, MonacoEditorModule, - ConfirmDialogModule + ConfirmDialogModule, + AutoCompleteModule, + DotMessagePipe, + InputGroupModule, + InputGroupAddonModule, + TooltipModule ], templateUrl: './dot-edit-content-wysiwyg-field.component.html', styleUrl: './dot-edit-content-wysiwyg-field.component.scss', host: { - class: 'wysiwyg__wrapper' + class: 'dot-wysiwyg__wrapper' }, changeDetection: ChangeDetectionStrategy.OnPush }) export class DotEditContentWYSIWYGFieldComponent implements AfterViewInit { + $tinyMCEComponent: Signal = viewChild( + DotWysiwygTinymceComponent + ); + $monacoComponent: Signal = + viewChild(DotWysiwygMonacoComponent); + #confirmationService = inject(ConfirmationService); #dotMessageService = inject(DotMessageService); + #dotLanguagesService = inject(DotLanguagesService); /** * This variable represents a required content type field in DotCMS. */ @@ -126,51 +150,36 @@ export class DotEditContentWYSIWYGFieldComponent implements AfterViewInit { return contentlet[fieldValue] as string; }); + readonly editorTypes = AvailableEditor; + readonly editorOptions = EditorOptions; + /** - * A computed property that determines the appropriate language mode for the content editor. - * This is based on the content type present in the editor. + * Signal to store the language variables, for later use in the autocomplete + * depending of the search query. */ - $contentLanguageUsed = computed(() => { - if (this.$contentEditorUsed() !== AvailableEditor.Monaco) { - return DEFAULT_MONACO_LANGUAGE; - } - - const content = this.$fieldContent(); - - if (!content) { - return DEFAULT_MONACO_LANGUAGE; - } - - if (JsKeywords.some((keyword) => content.includes(keyword))) { - return AvailableLanguageMonaco.Javascript; - } + $languageVariables = signal([]); - if (HtmlTags.some((tag) => content.indexOf(tag) !== -1)) { - return AvailableLanguageMonaco.Html; - } - - const mdScore = MdSyntax.reduce( - (score, syntax) => score + CountOccurrences(content, syntax), - 0 - ); - if (mdScore > 2) { - return AvailableLanguageMonaco.Markdown; - } + /** + * Signal to track if the user has interacted with the autocomplete. + * This is used to determine if the language variables should be loaded, and to avoid unnecessary loading. + */ + $hasInteracted = signal(false); - return AvailableLanguageMonaco.PlainText; - }); + /** + * Signal to store the selected item from the autocomplete. + */ + $selectedItem = signal(null); - readonly editorTypes = AvailableEditor; - readonly editorOptions = EditorOptions; - readonly monacoLanguagesOptions = MonacoLanguageOptions; + /** + * Signal to store the search query from the autocomplete. + */ + $searchQuery = signal(''); ngAfterViewInit(): void { // Assign the selected editor value this.$selectedEditorDropdown.set(this.$contentEditorUsed()); // Editor showed this.$displayedEditor.set(this.$contentEditorUsed()); - // Assign the selected language - this.$selectedLanguageDropdown.set(this.$contentLanguageUsed()); } /** @@ -206,4 +215,102 @@ export class DotEditContentWYSIWYGFieldComponent implements AfterViewInit { this.$displayedEditor.set(newEditor); } } + + /** + * Search for language variables. + * + * @param {AutoCompleteCompleteEvent} event - The event object containing the search query. + * @return {void} + */ + search(event: AutoCompleteCompleteEvent) { + if (event.query) { + this.$searchQuery.set(event.query); + } + + if (this.$languageVariables().length === 0) { + this.getLanguageVariables(); + } + } + + /** + * Computed property to filter the language variables based on the search query. + */ + $filteredSuggestions = computed(() => { + const term = this.$searchQuery().toLowerCase(); + + return this.$languageVariables() + .filter((variable) => variable.key.toLowerCase().includes(term)) + .slice(0, 10); + }); + + /** + * Handles the selection of a language variable from the autocomplete. + * + * @param {AutoCompleteSelectEvent} $event - The event object containing the selected value. + * @return {void} + */ + onSelectLanguageVariable($event: AutoCompleteSelectEvent) { + if (this.$displayedEditor() === AvailableEditor.TinyMCE) { + const tinyMCE = this.$tinyMCEComponent(); + if (tinyMCE) { + tinyMCE.insertContent(`$text.get('${$event.value.key}')`); + } else { + console.warn('TinyMCE component is not available'); + } + } else if (this.$displayedEditor() === AvailableEditor.Monaco) { + const monaco = this.$monacoComponent(); + if (monaco) { + monaco.insertContent(`$text.get('${$event.value.key}')`); + } else { + console.warn('Monaco component is not available'); + } + } + + this.$selectedItem.set(null); + } + + /** + * Fetches language variables from the DotCMS Languages API and formats them for use in the autocomplete. + */ + private getLanguageVariables() { + // TODO: This is a temporary solution to get the language variables from the DotCMS Languages API. + // We need a way to get the current language from the contentlet. + this.#dotLanguagesService + .getLanguageVariables() + .pipe(take(1)) + .subscribe({ + next: (variables) => { + const formattedVariables = Object.entries(variables) + .map(([key, langObj]) => { + // Try to get the English value first + let value = langObj['en-us']?.value; + + // If there is no English value, search for it in other languages + if (!value) { + for (const lang in langObj) { + if (langObj[lang]?.value) { + value = langObj[lang].value; + break; + } + } + } + + // If there is no value, use the key + if (!value) { + value = key; + } + + return { key, value }; + }) + .filter( + (variable) => variable.value !== null && variable.value !== undefined + ); + + this.$languageVariables.set(formattedVariables); + }, + error: (error) => { + console.error('Error fetching language variables:', error); + } + }); + } } diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.constant.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.constant.ts index a7f53f9b410f..0e6ded32f1e7 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.constant.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.constant.ts @@ -28,7 +28,8 @@ export enum AvailableLanguageMonaco { PlainText = 'plaintext', Javascript = 'javascript', Markdown = 'markdown', - Html = 'html' + Html = 'html', + Velocity = 'velocity' } /** @@ -80,7 +81,7 @@ export const DEFAULT_WYSIWYG_FIELD_MONACO_CONFIG: MonacoEditorConstructionOption ...DEFAULT_MONACO_CONFIG, language: DEFAULT_MONACO_LANGUAGE, automaticLayout: true, - theme: 'vs' + theme: 'dotTheme' }; /** @@ -120,7 +121,7 @@ export const DEFAULT_TINYMCE_CONFIG: Partial = { * - `\`\`\`` for code blocks. * - `>[` for blockquotes. */ -export const MdSyntax = ['# ', '## ', '### ', '- ', '* ', '1. ', '```', '>[']; +export const MD_SYNTAX = ['# ', '## ', '### ', '- ', '* ', '1. ', '```', '>[']; /** * HtmlTags is an array containing a list of common HTML tag names. @@ -131,7 +132,7 @@ export const MdSyntax = ['# ', '## ', '### ', '- ', '* ', '1. ', '```', '>[']; * * This array can be used to identify or manipulate these specific HTML elements in a web development context. */ -export const HtmlTags = ['', '', '', ' { - it('should correctly count markdown syntax occurrences', () => { - const markdownContent = '# Heading\n\n- List item\n\n```code block```'; - const mdScore = MdSyntax.reduce( - (score, syntax) => score + CountOccurrences(markdownContent, syntax), - 0 - ); - expect(mdScore).toBeGreaterThan(2); - }); +import { COMMENT_TINYMCE, MD_SYNTAX } from './dot-edit-content-wysiwyg-field.constant'; +import { + CountOccurrences, + isHtml, + isJavascript, + isMarkdown, + isVelocity, + shouldUseDefaultEditor +} from './dot-edit-content-wysiwyg-field.utils'; - it('should return low score for non-markdown content', () => { - const nonMarkdownContent = 'This is just plain text without any special syntax.'; - const mdScore = MdSyntax.reduce( - (score, syntax) => score + CountOccurrences(nonMarkdownContent, syntax), - 0 - ); - expect(mdScore).toBeLessThanOrEqual(2); - }); +describe('WYSIWYG Field Utils', () => { + describe('CountOccurrences', () => { + it('should correctly count markdown syntax occurrences', () => { + const markdownContent = '# Heading\n\n- List item\n\n```code block```'; + const mdScore = MD_SYNTAX.reduce( + (score, syntax) => score + CountOccurrences(markdownContent, syntax), + 0 + ); + expect(mdScore).toBeGreaterThan(2); + }); - it('should handle multiple occurrences of the same syntax', () => { - const repeatedSyntaxContent = '# Heading 1\n## Heading 2\n### Heading 3'; - const headingScore = CountOccurrences(repeatedSyntaxContent, '#'); - expect(headingScore).toBe(6); // 1 + 2 + 3 = 6 occurrences of '#' - }); -}); + it('should return low score for non-markdown content', () => { + const nonMarkdownContent = 'This is just plain text without any special syntax.'; + const mdScore = MD_SYNTAX.reduce( + (score, syntax) => score + CountOccurrences(nonMarkdownContent, syntax), + 0 + ); + expect(mdScore).toBeLessThanOrEqual(2); + }); -describe('shouldUseDefaultEditor', () => { - it('should return true for null or undefined', () => { - expect(shouldUseDefaultEditor(null)).toBe(true); - expect(shouldUseDefaultEditor(undefined)).toBe(true); + it('should handle multiple occurrences of the same syntax', () => { + const repeatedSyntaxContent = '# Heading 1\n## Heading 2\n### Heading 3'; + const headingScore = CountOccurrences(repeatedSyntaxContent, '#'); + expect(headingScore).toBe(6); // 1 + 2 + 3 = 6 occurrences of '#' + }); }); - it('should return true for non-string types', () => { - expect(shouldUseDefaultEditor(123)).toBe(true); - expect(shouldUseDefaultEditor({})).toBe(true); - expect(shouldUseDefaultEditor([])).toBe(true); + describe('shouldUseDefaultEditor', () => { + it('should return true for null or undefined', () => { + expect(shouldUseDefaultEditor(null)).toBe(true); + expect(shouldUseDefaultEditor(undefined)).toBe(true); + }); + + it('should return true for non-string types', () => { + expect(shouldUseDefaultEditor(123)).toBe(true); + expect(shouldUseDefaultEditor({})).toBe(true); + expect(shouldUseDefaultEditor([])).toBe(true); + }); + + it('should return true for empty string', () => { + expect(shouldUseDefaultEditor('')).toBe(true); + }); + + it('should return true for string with only whitespace', () => { + expect(shouldUseDefaultEditor(' ')).toBe(true); + }); + + it('should return true for COMMENT_TINYMCE', () => { + expect(shouldUseDefaultEditor(COMMENT_TINYMCE)).toBe(true); + }); + + it('should return false for non-empty strings', () => { + expect(shouldUseDefaultEditor('hello')).toBe(false); + }); }); - it('should return true for empty string', () => { - expect(shouldUseDefaultEditor('')).toBe(true); + describe('isVelocity', () => { + it('should return true for content with more than 2 Velocity patterns', () => { + const velocityContent = '#set($var = "value") #if($condition) $!variable #end'; + expect(isVelocity(velocityContent)).toBe(true); + }); + + it('should return false for content with 2 or fewer Velocity patterns', () => { + const nonVelocityContent = '#set($var = "value") Some other content'; + expect(isVelocity(nonVelocityContent)).toBe(false); + }); }); - it('should return true for string with only whitespace', () => { - expect(shouldUseDefaultEditor(' ')).toBe(true); + describe('isJavascript', () => { + it('should return true if content includes JavaScript keywords', () => { + const jsContent = 'function example() { const x = 10; return x; }'; + expect(isJavascript(jsContent)).toBe(true); + }); + + it('should return false if content does not include JavaScript keywords', () => { + const nonJsContent = 'This is just some plain text.'; + expect(isJavascript(nonJsContent)).toBe(false); + }); }); - it('should return true for COMMENT_TINYMCE', () => { - expect(shouldUseDefaultEditor(COMMENT_TINYMCE)).toBe(true); + describe('isHtml', () => { + it('should return true if content includes HTML tags', () => { + const htmlContent = '

This is a paragraph

'; + expect(isHtml(htmlContent)).toBe(true); + }); + + it('should return false if content does not include HTML tags', () => { + const nonHtmlContent = 'This is just some plain text.'; + expect(isHtml(nonHtmlContent)).toBe(false); + }); }); - it('should return false for non-empty strings', () => { - expect(shouldUseDefaultEditor('hello')).toBe(false); + describe('isMarkdown', () => { + it('should return true for content with more than 2 Markdown syntax occurrences', () => { + const markdownContent = '# Heading\n\n- List item\n\n```code block```'; + expect(isMarkdown(markdownContent)).toBe(true); + }); + + it('should return false for content with 2 or fewer Markdown syntax occurrences', () => { + const nonMarkdownContent = 'This is just plain text with *one* emphasis.'; + expect(isMarkdown(nonMarkdownContent)).toBe(false); + }); }); }); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.utils.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.utils.ts index 318a5f9b2283..cfef471bc7cc 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.utils.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.utils.ts @@ -1,15 +1,37 @@ -import { COMMENT_TINYMCE } from './dot-edit-content-wysiwyg-field.constant'; +import { + COMMENT_TINYMCE, + HTML_TAGS, + JS_KEYWORDS, + MD_SYNTAX, + VELOCITY_PATTERNS +} from './dot-edit-content-wysiwyg-field.constant'; +/** + * Escapes special characters in a string for use in a regular expression. + * @param {string} string - The string to escape. + * @returns {string} The escaped string. + */ const escapeRegExp = (string: string) => { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }; +/** + * Counts the occurrences of a substring within a string. + * @param {string} str - The string to search in. + * @param {string} searchStr - The substring to search for. + * @returns {number} The number of occurrences. + */ export const CountOccurrences = (str: string, searchStr: string) => { const escapedSearchStr = escapeRegExp(searchStr); return (str.match(new RegExp(escapedSearchStr, 'gi')) || []).length; }; +/** + * Determines if the default editor should be used based on the content. + * @param {unknown} content - The content to check. + * @returns {boolean} True if the default editor should be used, false otherwise. + */ export const shouldUseDefaultEditor = (content: unknown): boolean => { return ( !content || @@ -18,3 +40,49 @@ export const shouldUseDefaultEditor = (content: unknown): boolean => { content.trim() === COMMENT_TINYMCE ); }; + +/** + * Checks if the content is likely to be Velocity code. + * @param {string} content - The content to check. + * @returns {boolean} True if the content is likely Velocity, false otherwise. + */ +export const isVelocity = (content: string): boolean => { + const velocityScore = VELOCITY_PATTERNS.reduce( + (score, pattern) => score + (pattern.test(content) ? 1 : 0), + 0 + ); + + return velocityScore > 2; +}; + +/** + * Checks if the content is likely to be JavaScript code. + * @param {string} content - The content to check. + * @returns {boolean} True if the content is likely JavaScript, false otherwise. + */ +export const isJavascript = (content: string): boolean => { + return JS_KEYWORDS.some((keyword) => content.includes(keyword)); +}; + +/** + * Checks if the content is likely to be HTML. + * @param {string} content - The content to check. + * @returns {boolean} True if the content is likely HTML, false otherwise. + */ +export const isHtml = (content: string): boolean => { + return HTML_TAGS.some((tag) => content.indexOf(tag) !== -1); +}; + +/** + * Checks if the content is likely to be Markdown. + * @param {string} content - The content to check. + * @returns {boolean} True if the content is likely Markdown, false otherwise. + */ +export const isMarkdown = (content: string): boolean => { + const mdScore = MD_SYNTAX.reduce( + (score, syntax) => score + CountOccurrences(content, syntax), + 0 + ); + + return mdScore > 2; +}; diff --git a/core-web/libs/utils-testing/src/lib/monaco-editor.mock.ts b/core-web/libs/utils-testing/src/lib/monaco-editor.mock.ts index 335ea88d50c4..4e737d2e2eb9 100644 --- a/core-web/libs/utils-testing/src/lib/monaco-editor.mock.ts +++ b/core-web/libs/utils-testing/src/lib/monaco-editor.mock.ts @@ -72,7 +72,8 @@ export const monacoMock = { languages: { register: () => {}, registerCompletionItemProvider: () => {}, - registerDefinitionProvider: () => {} + registerDefinitionProvider: () => {}, + setMonarchTokensProvider: () => {} }, Uri: { parse: () => ({}), diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 9f31355e46c9..20d5f8994b9d 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -5803,10 +5803,15 @@ edit.content.category-field.search.empty.legend=To create a new category, naviga edit.content.wysiwyg.confirm.switch-editor.header=Confirm View Change edit.content.wysiwyg.confirm.switch-editor.message=Switching to the WYSIWYG view may change your code and cause code loss.
Are you sure you want to continue? +edit.content.wysiwyg-field.language-variable-placeholder=Language variables +edit.content.wysiwyg-field.language-variable-tooltip=Start typing to see matching Language Variables. lts.expired.message = This version of dotCMS already reached EOL. Please contact your CSM to schedule an upgrade. lts.expires.soon.message = Your dotCMS version will reach EOL in {0} days. Please contact your CSM to schedule an upgrade. + analytics.search.run.query=Run Query analytics.search.query=Query -analytics.search.results=Results \ No newline at end of file +analytics.search.results=Results + +