diff --git a/apps/client/src/widgets/type_widgets/editable_text.test.ts b/apps/client/src/widgets/type_widgets/editable_text.test.ts new file mode 100644 index 0000000000..3a95837c6b --- /dev/null +++ b/apps/client/src/widgets/type_widgets/editable_text.test.ts @@ -0,0 +1,295 @@ +import { describe, it, expect } from 'vitest'; + +// Mock the EditableTextTypeWidget class to test the escaping methods +class MockEditableTextTypeWidget { + private escapeGenericTypeSyntax(content: string): string { + if (!content) return content; + + try { + // Count replacements for debugging + let replacementCount = 0; + + // List of known HTML tags that should NOT be escaped + const htmlTags = new Set([ + // Block elements + 'div', 'p', 'section', 'article', 'nav', 'header', 'footer', 'aside', 'main', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'ul', 'ol', 'li', 'dl', 'dt', 'dd', + 'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'caption', 'colgroup', 'col', + 'form', 'fieldset', 'legend', + 'blockquote', 'pre', 'figure', 'figcaption', + 'address', 'hr', 'br', + + // Inline elements + 'span', 'a', 'strong', 'em', 'b', 'i', 'u', 's', 'del', 'ins', + 'small', 'mark', 'sub', 'sup', + 'code', 'kbd', 'samp', 'var', + 'q', 'cite', 'abbr', 'dfn', 'time', + 'img', 'picture', 'source', + + // Form elements + 'input', 'textarea', 'button', 'select', 'option', 'optgroup', + 'label', 'output', 'progress', 'meter', + + // Media elements + 'audio', 'video', 'track', + 'canvas', 'svg', + + // Metadata elements + 'head', 'title', 'meta', 'link', 'style', 'script', 'noscript', + 'base', + + // Document structure + 'html', 'body', + + // Other common elements + 'iframe', 'embed', 'object', 'param', + 'details', 'summary', 'dialog', + 'template', 'slot', + 'area', 'map', + 'ruby', 'rt', 'rp', + 'bdi', 'bdo', 'wbr', + 'data', 'datalist', + 'keygen', 'output', + 'math', 'mi', 'mo', 'mn', 'ms', 'mtext', 'mspace', + + // Custom elements that Trilium uses + 'includenote' + ]); + + // More comprehensive escaping strategy: + // We'll use a different approach - parse through the content and identify + // what looks like HTML vs what looks like generic type syntax + + // First pass: Protect actual HTML tags by temporarily replacing them + const htmlProtectionMap = new Map(); + let protectionCounter = 0; + + // Protect complete HTML tags (opening, closing, and self-closing) + content = content.replace(/<\/?([a-zA-Z][a-zA-Z0-9-]*)(?:\s+[^>]*)?\/?>|/g, (match, tagName) => { + // Check if this is a comment + if (match.startsWith(''; + const expected = ''; + expect(widget.testEscape(input)).toBe(expected); + }); + + it('should handle pre-escaped content correctly', () => { + // If content already has HTML entities, they get preserved during escaping + // (they don't match our angle bracket patterns) + const input = 'Already escaped: <String> and new: '; + const expected = 'Already escaped: <String> and new: <Integer>'; + expect(widget.testEscape(input)).toBe(expected); + + // When unescaping, ALL < and > entities get unescaped + // This is correct behavior because CKEditor expects raw HTML + const unescaped = widget.testUnescape(expected); + expect(unescaped).toBe('Already escaped: and new: '); + }); + + it('should handle HTML with inline code containing generics', () => { + const input = '

Use Vec for dynamic arrays

'; + const expected = '

Use Vec<T> for dynamic arrays

'; + expect(widget.testEscape(input)).toBe(expected); + }); + + it('should handle self-closing HTML tags', () => { + const input = '
'; + const expected = '
<CustomType>'; + expect(widget.testEscape(input)).toBe(expected); + }); +}); \ No newline at end of file diff --git a/apps/client/src/widgets/type_widgets/editable_text.ts b/apps/client/src/widgets/type_widgets/editable_text.ts index e1163e2b53..0235c034a3 100644 --- a/apps/client/src/widgets/type_widgets/editable_text.ts +++ b/apps/client/src/widgets/type_widgets/editable_text.ts @@ -14,6 +14,7 @@ import type FNote from "../../entities/fnote.js"; import { PopupEditor, ClassicEditor, EditorWatchdog, type CKTextEditor, type MentionFeed, type WatchdogConfig, EditorConfig } from "@triliumnext/ckeditor5"; import "@triliumnext/ckeditor5/index.css"; import { updateTemplateCache } from "./ckeditor/snippets.js"; +import { SANITIZER_DEFAULT_ALLOWED_TAGS } from "@triliumnext/commons"; const TPL = /*html*/`
@@ -239,12 +240,26 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { const blob = await note.getBlob(); await this.spacedUpdate.allowUpdateWithoutChange(async () => { - const data = blob?.content || ""; - const newContentLanguage = this.note?.getLabelValue("language"); - if (this.contentLanguage !== newContentLanguage) { - await this.reinitializeWithData(data); - } else { - this.watchdog.editor?.setData(data); + let data = blob?.content || ""; + + try { + // Escape generic type syntax that could be mistaken for HTML tags + data = this.escapeGenericTypeSyntax(data); + + const newContentLanguage = this.note?.getLabelValue("language"); + if (this.contentLanguage !== newContentLanguage) { + await this.reinitializeWithData(data); + } else { + this.watchdog.editor?.setData(data); + } + } catch (error) { + logError(`Failed to set editor data for note ${note.noteId}: ${error}`); + // Try to set the data without escaping as a fallback + try { + this.watchdog.editor?.setData(blob?.content || ""); + } catch (fallbackError) { + logError(`Fallback also failed: ${fallbackError}`); + } } }); } @@ -255,7 +270,10 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { return; } - const content = this.watchdog.editor?.getData() ?? ""; + let content = this.watchdog.editor?.getData() ?? ""; + + // Unescape any generic type syntax we escaped earlier + content = this.unescapeGenericTypeSyntax(content); // if content is only tags/whitespace (typically

 

), then just make it empty, // this is important when setting a new note to code @@ -488,9 +506,23 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { return; } - this.watchdog.destroy(); - await this.createEditor(); - this.watchdog.editor?.setData(data); + try { + this.watchdog.destroy(); + await this.createEditor(); + // Data should already be escaped when this is called from doRefresh + // but we ensure it's escaped in case this is called from elsewhere + const escapedData = data.includes('<') ? data : this.escapeGenericTypeSyntax(data); + this.watchdog.editor?.setData(escapedData); + } catch (error) { + logError(`Failed to reinitialize editor with data: ${error}`); + // Try to create editor without data and set it later + try { + await this.createEditor(); + this.watchdog.editor?.setData(""); + } catch (fallbackError) { + logError(`Failed to create empty editor: ${fallbackError}`); + } + } } async reinitialize() { @@ -530,6 +562,109 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { } } + /** + * Escapes generic type syntax (e.g., ) that could be mistaken for HTML tags. + * This prevents CKEditor from trying to parse them as DOM elements. + */ + private escapeGenericTypeSyntax(content: string): string { + if (!content) return content; + + try { + // Count replacements for debugging + let replacementCount = 0; + + // Get the allowed HTML tags from user settings, with fallback to default list + let allowedTags; + try { + const allowedHtmlTagsOption = options.get("allowedHtmlTags"); + allowedTags = allowedHtmlTagsOption ? JSON.parse(allowedHtmlTagsOption) : SANITIZER_DEFAULT_ALLOWED_TAGS; + } catch (e) { + // Fallback to default list if option doesn't exist or is invalid JSON + allowedTags = SANITIZER_DEFAULT_ALLOWED_TAGS; + } + + // Convert to lowercase for case-insensitive comparison + const htmlTags = new Set( + allowedTags.map((tag: string) => tag.toLowerCase()) + ); + + // Add custom Trilium element that must be preserved + htmlTags.add('includenote'); + + // More comprehensive escaping strategy: + // We'll use a different approach - parse through the content and identify + // what looks like HTML vs what looks like generic type syntax + + // First pass: Protect actual HTML tags by temporarily replacing them + const htmlProtectionMap = new Map(); + let protectionCounter = 0; + + // Protect complete HTML tags (opening, closing, and self-closing) + content = content.replace(/<\/?([a-zA-Z][a-zA-Z0-9-]*)(?:\s+[^>]*)?\/?>|/g, (match, tagName) => { + // Check if this is a comment + if (match.startsWith('