@ceedcv-maya/shared-editor-react 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +66 -0
  3. package/package.json +87 -0
  4. package/src/components/ColorPicker.tsx +100 -0
  5. package/src/components/CommentHoverPopover.tsx +82 -0
  6. package/src/components/EditorContentHtml.tsx +29 -0
  7. package/src/components/EditorToolbar.tsx +225 -0
  8. package/src/components/EditorToolbarButton.tsx +32 -0
  9. package/src/components/EditorToolbarGroups.tsx +401 -0
  10. package/src/components/FindReplaceBar.tsx +253 -0
  11. package/src/components/MayaEditor.tsx +379 -0
  12. package/src/components/SourceInputDialog.tsx +120 -0
  13. package/src/extensions/AlertBlock.ts +59 -0
  14. package/src/extensions/CommentMark.ts +57 -0
  15. package/src/extensions/IframeBlock.ts +76 -0
  16. package/src/extensions/Indent.ts +133 -0
  17. package/src/hooks/useEditorContent.ts +47 -0
  18. package/src/i18n/en.json +54 -0
  19. package/src/i18n/es.json +54 -0
  20. package/src/index.ts +47 -0
  21. package/src/lib/CommentAnchor.ts +68 -0
  22. package/src/lib/docxToHtml.ts +58 -0
  23. package/src/lib/dompurifyConfig.test.ts +98 -0
  24. package/src/lib/dompurifyConfig.ts +123 -0
  25. package/src/lib/editorExtensions.ts +73 -0
  26. package/src/lib/htmlToMarkdown.ts +166 -0
  27. package/src/lib/htmlToTiptapDoc.test.ts +52 -0
  28. package/src/lib/htmlToTiptapDoc.ts +26 -0
  29. package/src/lib/markdownToHtml.ts +234 -0
  30. package/src/lib/normalizeTableHtml.ts +74 -0
  31. package/src/lib/splitHtmlIntoBlocks.test.ts +86 -0
  32. package/src/lib/splitHtmlIntoBlocks.ts +136 -0
  33. package/src/serializers/BlockNoteToTiptap.ts +223 -0
  34. package/src/styles/maya-editor.css +538 -0
  35. package/src/types.ts +56 -0
  36. package/tsconfig.json +20 -0
@@ -0,0 +1,133 @@
1
+ import { Extension } from '@tiptap/core';
2
+
3
+ declare module '@tiptap/core' {
4
+ interface Commands<ReturnType> {
5
+ indent: {
6
+ indent: () => ReturnType;
7
+ outdent: () => ReturnType;
8
+ };
9
+ }
10
+ }
11
+
12
+ export interface IndentOptions {
13
+ types: string[];
14
+ minLevel: number;
15
+ maxLevel: number;
16
+ }
17
+
18
+ /**
19
+ * Adds an `indent` attribute (integer level) to paragraph/heading nodes
20
+ * and exposes `indent()` / `outdent()` commands.
21
+ *
22
+ * Lists and task lists already have native indent via Tab/Shift-Tab from
23
+ * StarterKit's `listKeymap`; this extension covers the rest of the
24
+ * schema so the toolbar Indent button works on any block.
25
+ *
26
+ * Rendered as inline `style="margin-left: <level * step>em"` so the
27
+ * output round-trips through the SSR renderer and copy-pastes elsewhere
28
+ * without losing the visual.
29
+ */
30
+ export const Indent = Extension.create<IndentOptions>({
31
+ name: 'indent',
32
+
33
+ addOptions() {
34
+ return {
35
+ types: ['paragraph', 'heading', 'blockquote'],
36
+ minLevel: 0,
37
+ maxLevel: 8,
38
+ };
39
+ },
40
+
41
+ addGlobalAttributes() {
42
+ return [
43
+ {
44
+ types: this.options.types,
45
+ attributes: {
46
+ indent: {
47
+ default: 0,
48
+ parseHTML: (element) => {
49
+ const attr = element.getAttribute('data-indent');
50
+ if (attr) return parseInt(attr, 10) || 0;
51
+ const ml = (element as HTMLElement).style?.marginLeft;
52
+ if (ml && ml.endsWith('em')) {
53
+ return Math.max(0, Math.round(parseFloat(ml) / 2));
54
+ }
55
+ return 0;
56
+ },
57
+ renderHTML: (attrs) => {
58
+ const level = Number(attrs.indent) || 0;
59
+ if (level <= 0) return {};
60
+ return {
61
+ 'data-indent': String(level),
62
+ style: `margin-left: ${level * 2}em`,
63
+ };
64
+ },
65
+ },
66
+ },
67
+ },
68
+ ];
69
+ },
70
+
71
+ addCommands() {
72
+ return {
73
+ indent:
74
+ () =>
75
+ ({ state, tr, dispatch }) => {
76
+ const { selection } = state;
77
+ let changed = false;
78
+ state.doc.nodesBetween(selection.from, selection.to, (node, pos) => {
79
+ if (!this.options.types.includes(node.type.name)) return;
80
+ const current = (node.attrs?.indent as number) ?? 0;
81
+ const next = Math.min(this.options.maxLevel, current + 1);
82
+ if (next === current) return;
83
+ if (dispatch) tr.setNodeMarkup(pos, undefined, { ...node.attrs, indent: next });
84
+ changed = true;
85
+ });
86
+ return changed;
87
+ },
88
+ outdent:
89
+ () =>
90
+ ({ state, tr, dispatch }) => {
91
+ const { selection } = state;
92
+ let changed = false;
93
+ state.doc.nodesBetween(selection.from, selection.to, (node, pos) => {
94
+ if (!this.options.types.includes(node.type.name)) return;
95
+ const current = (node.attrs?.indent as number) ?? 0;
96
+ const next = Math.max(this.options.minLevel, current - 1);
97
+ if (next === current) return;
98
+ if (dispatch) tr.setNodeMarkup(pos, undefined, { ...node.attrs, indent: next });
99
+ changed = true;
100
+ });
101
+ return changed;
102
+ },
103
+ };
104
+ },
105
+
106
+ addKeyboardShortcuts() {
107
+ return {
108
+ Tab: () => {
109
+ // Don't steal Tab from lists/tasks/tables — those have their own handlers.
110
+ const { state } = this.editor;
111
+ const { $from } = state.selection;
112
+ for (let depth = $from.depth; depth > 0; depth--) {
113
+ const name = $from.node(depth).type.name;
114
+ if (['listItem', 'taskItem', 'tableCell', 'tableHeader'].includes(name)) {
115
+ return false;
116
+ }
117
+ }
118
+ return this.editor.commands.indent();
119
+ },
120
+ 'Shift-Tab': () => {
121
+ const { state } = this.editor;
122
+ const { $from } = state.selection;
123
+ for (let depth = $from.depth; depth > 0; depth--) {
124
+ const name = $from.node(depth).type.name;
125
+ if (['listItem', 'taskItem', 'tableCell', 'tableHeader'].includes(name)) {
126
+ return false;
127
+ }
128
+ }
129
+ return this.editor.commands.outdent();
130
+ },
131
+ };
132
+ },
133
+ });
@@ -0,0 +1,47 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import type { Editor } from '@tiptap/react';
3
+ import type { TiptapDoc } from '../types';
4
+
5
+ export type EditorOutput = 'html' | 'json';
6
+
7
+ /**
8
+ * Debounced onChange wiring for a TipTap editor instance.
9
+ *
10
+ * The payload shape is selected by `output`:
11
+ * - 'html' (default for `<MayaEditor mode="lite">`) — string HTML, fed
12
+ * straight to a sanitiser before persistence.
13
+ * - 'json' (default for `<MayaEditor mode="full">`) — full ProseMirror
14
+ * doc object `{ type: 'doc', content: [...] }`, structurally equivalent
15
+ * to BlockNote's legacy block array so backends that stored BlockNote
16
+ * JSON can keep their `array | object` validation rules.
17
+ */
18
+ export function useEditorContent(
19
+ editor: Editor | null,
20
+ onChange: ((payload: string | TiptapDoc) => void) | undefined,
21
+ options: { output?: EditorOutput; delayMs?: number } = {},
22
+ ): void {
23
+ const { output = 'html', delayMs = 300 } = options;
24
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
25
+ const handlerRef = useRef(onChange);
26
+ handlerRef.current = onChange;
27
+
28
+ useEffect(() => {
29
+ if (!editor) return;
30
+
31
+ const handleUpdate = () => {
32
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
33
+ timeoutRef.current = setTimeout(() => {
34
+ const payload = output === 'json'
35
+ ? (editor.getJSON() as TiptapDoc)
36
+ : editor.getHTML();
37
+ handlerRef.current?.(payload);
38
+ }, delayMs);
39
+ };
40
+
41
+ editor.on('update', handleUpdate);
42
+ return () => {
43
+ editor.off('update', handleUpdate);
44
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
45
+ };
46
+ }, [editor, delayMs, output]);
47
+ }
@@ -0,0 +1,54 @@
1
+ {
2
+ "editor": {
3
+ "toolbar": {
4
+ "bold": "Bold",
5
+ "italic": "Italic",
6
+ "underline": "Underline",
7
+ "strike": "Strikethrough",
8
+ "code": "Code",
9
+ "link": "Insert link",
10
+ "linkPrompt": "Link URL",
11
+ "unlink": "Remove link",
12
+ "heading1": "Heading 1",
13
+ "heading2": "Heading 2",
14
+ "heading3": "Heading 3",
15
+ "paragraph": "Paragraph",
16
+ "bulletList": "Bullet list",
17
+ "orderedList": "Numbered list",
18
+ "taskList": "Task list",
19
+ "blockquote": "Quote",
20
+ "codeBlock": "Code block",
21
+ "horizontalRule": "Horizontal rule",
22
+ "image": "Insert image",
23
+ "table": "Insert table",
24
+ "alert": "Alert",
25
+ "iframe": "Insert iframe",
26
+ "iframePrompt": "Iframe URL",
27
+ "fullscreen": "Fullscreen",
28
+ "exitFullscreen": "Exit fullscreen",
29
+ "addComment": "Comment selection",
30
+ "importDocx": "Import .docx",
31
+ "exportDocx": "Export as .docx",
32
+ "insertHtml": "Insert HTML",
33
+ "insertMarkdown": "Insert Markdown",
34
+ "undo": "Undo",
35
+ "redo": "Redo",
36
+ "uploadImage": "Insert image",
37
+ "find": "Find and replace",
38
+ "findPlaceholder": "Find",
39
+ "replacePlaceholder": "Replace with",
40
+ "findNext": "Next match",
41
+ "findPrev": "Previous match",
42
+ "replace": "Replace",
43
+ "replaceAll": "Replace all",
44
+ "findClose": "Close find",
45
+ "caseSensitive": "Match case",
46
+ "findNone": "No matches"
47
+ },
48
+ "placeholder": "Start typing or press / for commands…",
49
+ "errors": {
50
+ "uploadFailed": "File upload failed",
51
+ "linkInvalid": "Invalid URL"
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,54 @@
1
+ {
2
+ "editor": {
3
+ "toolbar": {
4
+ "bold": "Negrita",
5
+ "italic": "Cursiva",
6
+ "underline": "Subrayado",
7
+ "strike": "Tachado",
8
+ "code": "Código",
9
+ "link": "Insertar enlace",
10
+ "linkPrompt": "URL del enlace",
11
+ "unlink": "Quitar enlace",
12
+ "heading1": "Título 1",
13
+ "heading2": "Título 2",
14
+ "heading3": "Título 3",
15
+ "paragraph": "Párrafo",
16
+ "bulletList": "Lista",
17
+ "orderedList": "Lista numerada",
18
+ "taskList": "Lista de tareas",
19
+ "blockquote": "Cita",
20
+ "codeBlock": "Bloque de código",
21
+ "horizontalRule": "Separador",
22
+ "image": "Insertar imagen",
23
+ "table": "Insertar tabla",
24
+ "alert": "Aviso",
25
+ "iframe": "Insertar iframe",
26
+ "iframePrompt": "URL del iframe",
27
+ "fullscreen": "Pantalla completa",
28
+ "exitFullscreen": "Salir de pantalla completa",
29
+ "addComment": "Comentar selección",
30
+ "importDocx": "Importar .docx",
31
+ "exportDocx": "Exportar como .docx",
32
+ "insertHtml": "Insertar HTML",
33
+ "insertMarkdown": "Insertar Markdown",
34
+ "undo": "Deshacer",
35
+ "redo": "Rehacer",
36
+ "uploadImage": "Insertar imagen",
37
+ "find": "Buscar y reemplazar",
38
+ "findPlaceholder": "Buscar",
39
+ "replacePlaceholder": "Reemplazar por",
40
+ "findNext": "Siguiente",
41
+ "findPrev": "Anterior",
42
+ "replace": "Reemplazar",
43
+ "replaceAll": "Reemplazar todo",
44
+ "findClose": "Cerrar",
45
+ "caseSensitive": "Distinguir mayúsculas",
46
+ "findNone": "Sin coincidencias"
47
+ },
48
+ "placeholder": "Empieza a escribir o pulsa / para comandos…",
49
+ "errors": {
50
+ "uploadFailed": "No se pudo subir el archivo",
51
+ "linkInvalid": "URL no válida"
52
+ }
53
+ }
54
+ }
package/src/index.ts ADDED
@@ -0,0 +1,47 @@
1
+ export { MayaEditor } from './components/MayaEditor';
2
+ export type { MayaEditorProps } from './components/MayaEditor';
3
+ export { EditorContentHtml } from './components/EditorContentHtml';
4
+ export { EditorToolbar } from './components/EditorToolbar';
5
+ export type { ToolbarLabels } from './components/EditorToolbar';
6
+
7
+ export { IframeBlock } from './extensions/IframeBlock';
8
+ export { AlertBlock } from './extensions/AlertBlock';
9
+ export type { AlertVariant } from './extensions/AlertBlock';
10
+ export { CommentMark } from './extensions/CommentMark';
11
+ export { Indent } from './extensions/Indent';
12
+ export type { IndentOptions } from './extensions/Indent';
13
+ export { ColorPicker } from './components/ColorPicker';
14
+
15
+ export { convertBlockNoteToTiptap } from './serializers/BlockNoteToTiptap';
16
+ export { useEditorContent } from './hooks/useEditorContent';
17
+ export { getAnchorRange, setAnchorRange, rebaseAnchors } from './lib/CommentAnchor';
18
+ export type { AnchorRange } from './lib/CommentAnchor';
19
+
20
+ export { sanitizeEditorHtml, ALLOWED_TAGS, ALLOWED_ATTR } from './lib/dompurifyConfig';
21
+ export { markdownToHtml } from './lib/markdownToHtml';
22
+ export { htmlToMarkdown } from './lib/htmlToMarkdown';
23
+ export { normalizeTableHtml } from './lib/normalizeTableHtml';
24
+ export { splitHtmlIntoBlocks } from './lib/splitHtmlIntoBlocks';
25
+ export type { BlockChunk, BlockChunkType } from './lib/splitHtmlIntoBlocks';
26
+ export { htmlToTiptapDoc } from './lib/htmlToTiptapDoc';
27
+ export { buildMayaEditorExtensions } from './lib/editorExtensions';
28
+ export { docxToHtml, docxToHtmlResult } from './lib/docxToHtml';
29
+ export type { DocxConversionMessage, DocxConversionResult } from './lib/docxToHtml';
30
+ export { SourceInputDialog } from './components/SourceInputDialog';
31
+ export { FindReplaceBar } from './components/FindReplaceBar';
32
+ export { CommentHoverPopover } from './components/CommentHoverPopover';
33
+ export type { CommentHoverData } from './components/CommentHoverPopover';
34
+
35
+ export type {
36
+ EditorMode,
37
+ TiptapMark,
38
+ TiptapNode,
39
+ TiptapDoc,
40
+ AnchoredComment,
41
+ BlockNoteBlock,
42
+ BlockNoteInline,
43
+ BlockNoteStyles,
44
+ } from './types';
45
+
46
+ export { default as esTranslations } from './i18n/es.json';
47
+ export { default as enTranslations } from './i18n/en.json';
@@ -0,0 +1,68 @@
1
+ import type { Editor } from '@tiptap/react';
2
+ import type { Transaction } from '@tiptap/pm/state';
3
+ import type { AnchoredComment } from '../types';
4
+
5
+ /**
6
+ * Anchored-comment helpers that rebase positions across edits using
7
+ * ProseMirror's `Transaction.mapping`.
8
+ *
9
+ * Stable semantics:
10
+ * - Insertions BEFORE the anchor → both `from` and `to` shift right.
11
+ * - Insertions INSIDE the anchor → `to` shifts right; `from` unchanged.
12
+ * - Deletions overlapping fully → anchor becomes invalid (`from === to`).
13
+ * - Deletions partial → range collapses to the surviving slice.
14
+ */
15
+ export interface AnchorRange {
16
+ from: number;
17
+ to: number;
18
+ }
19
+
20
+ export function getAnchorRange(editor: Editor, commentId: string | number): AnchorRange | null {
21
+ let found: AnchorRange | null = null;
22
+ editor.state.doc.descendants((node, pos) => {
23
+ if (found) return false;
24
+ node.marks.forEach((mark) => {
25
+ if (mark.type.name === 'comment' && String(mark.attrs.commentId) === String(commentId)) {
26
+ found = { from: pos, to: pos + node.nodeSize };
27
+ }
28
+ });
29
+ return true;
30
+ });
31
+ return found;
32
+ }
33
+
34
+ export function setAnchorRange(
35
+ editor: Editor,
36
+ commentId: string | number,
37
+ range: AnchorRange,
38
+ ): boolean {
39
+ return editor
40
+ .chain()
41
+ .focus()
42
+ .setTextSelection(range)
43
+ .setComment(commentId)
44
+ .run();
45
+ }
46
+
47
+ /**
48
+ * Rebase a list of anchors against a ProseMirror transaction's mapping.
49
+ * Returns the updated anchors; entries whose range collapsed to zero
50
+ * width are marked `anchorIsValid: false` for server reconciliation.
51
+ */
52
+ export function rebaseAnchors(
53
+ anchors: AnchoredComment[],
54
+ tr: Transaction,
55
+ ): AnchoredComment[] {
56
+ const mapping = tr.mapping;
57
+ return anchors.map((anchor) => {
58
+ const newFrom = mapping.map(anchor.anchorFrom, 1);
59
+ const newTo = mapping.map(anchor.anchorTo, -1);
60
+ const collapsed = newFrom >= newTo;
61
+ return {
62
+ ...anchor,
63
+ anchorFrom: newFrom,
64
+ anchorTo: newTo,
65
+ anchorIsValid: anchor.anchorIsValid && !collapsed,
66
+ };
67
+ });
68
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Convert a `.docx` File to sanitised, editor-ready HTML.
3
+ *
4
+ * Mammoth runs entirely client-side and is loaded via dynamic `import()` so
5
+ * its ~430KB bundle only ships to apps that exercise the .docx flow. The
6
+ * `mammoth/mammoth.browser.js` entry is required — the package root pulls
7
+ * Node-specific code that breaks under Vite's browser optimisation.
8
+ *
9
+ * Output passes through the same `normalizeTableHtml` + `sanitizeEditorHtml`
10
+ * pipeline the live editor uses, so the result is safe to drop straight into
11
+ * a TipTap doc (see {@link htmlToTiptapDoc}) or split into block chunks
12
+ * (see {@link splitHtmlIntoBlocks}).
13
+ */
14
+ import { normalizeTableHtml } from './normalizeTableHtml';
15
+ import { sanitizeEditorHtml } from './dompurifyConfig';
16
+
17
+ export interface DocxConversionMessage {
18
+ type: string;
19
+ message: string;
20
+ }
21
+
22
+ export interface DocxConversionResult {
23
+ /** Sanitised, editor-ready HTML. */
24
+ html: string;
25
+ /** Warnings/errors emitted by mammoth (unrecognised styles, dropped tags…). */
26
+ messages: DocxConversionMessage[];
27
+ }
28
+
29
+ type MammothConvert = (input: {
30
+ arrayBuffer: ArrayBuffer;
31
+ }) => Promise<{ value: string; messages?: DocxConversionMessage[] }>;
32
+
33
+ /**
34
+ * Convert .docx to HTML with full diagnostic messages.
35
+ * Use this when you need to show users warnings about unsupported Word features
36
+ * (e.g., "Warning: dropped unsupported style 'MyStyle'").
37
+ * The messages array contains any warnings/errors from the mammoth parser.
38
+ */
39
+ export async function docxToHtmlResult(file: File): Promise<DocxConversionResult> {
40
+ const mod = (await import('mammoth/mammoth.browser.js')) as unknown as {
41
+ convertToHtml: MammothConvert;
42
+ default?: { convertToHtml: MammothConvert };
43
+ };
44
+ const mammoth = mod.default ?? mod;
45
+ const buffer = await file.arrayBuffer();
46
+ const { value: rawHtml, messages = [] } = await mammoth.convertToHtml({ arrayBuffer: buffer });
47
+ return { html: sanitizeEditorHtml(normalizeTableHtml(rawHtml)), messages };
48
+ }
49
+
50
+ /**
51
+ * Convenience wrapper that returns only the sanitised HTML.
52
+ * Use this when you don't need diagnostic messages and just want clean HTML
53
+ * ready to insert into the editor.
54
+ */
55
+ export async function docxToHtml(file: File): Promise<string> {
56
+ const { html } = await docxToHtmlResult(file);
57
+ return html;
58
+ }
@@ -0,0 +1,98 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { sanitizeEditorHtml } from './dompurifyConfig';
3
+
4
+ describe('dompurifyConfig - XSS protection', () => {
5
+ it('blocks javascript: URLs in href', () => {
6
+ const html = '<a href="javascript:alert(1)">click</a>';
7
+ const result = sanitizeEditorHtml(html);
8
+ expect(result).not.toContain('javascript:');
9
+ // DOMPurify removes the href attribute entirely for invalid schemes
10
+ expect(result).toContain('<a>click</a>');
11
+ });
12
+
13
+ it('blocks data: URLs in href', () => {
14
+ const html = '<a href="data:text/html,<script>alert(1)</script>">click</a>';
15
+ const result = sanitizeEditorHtml(html);
16
+ expect(result).not.toContain('data:');
17
+ });
18
+
19
+ it('blocks vbscript: URLs in href', () => {
20
+ const html = '<a href="vbscript:msgbox(1)">click</a>';
21
+ const result = sanitizeEditorHtml(html);
22
+ expect(result).not.toContain('vbscript:');
23
+ });
24
+
25
+ it('strips onclick event handler', () => {
26
+ const html = '<p onclick="alert(1)">text</p>';
27
+ const result = sanitizeEditorHtml(html);
28
+ expect(result).not.toContain('onclick');
29
+ });
30
+
31
+ it('strips onerror event handler on img', () => {
32
+ const html = '<img src="x" onerror="alert(1)" alt="test">';
33
+ const result = sanitizeEditorHtml(html);
34
+ expect(result).not.toContain('onerror');
35
+ });
36
+
37
+ it('strips script tags', () => {
38
+ const html = '<p>safe</p><script>alert(1)</script>';
39
+ const result = sanitizeEditorHtml(html);
40
+ expect(result).not.toContain('<script>');
41
+ expect(result).toContain('safe');
42
+ });
43
+
44
+ it('allows iframe with src attribute', () => {
45
+ // DOMPurify preserves iframe and src attribute
46
+ const html = '<iframe src="https://example.com" sandbox="allow-scripts"></iframe>';
47
+ const result = sanitizeEditorHtml(html);
48
+ expect(result).toContain('<iframe');
49
+ expect(result).toContain('https://example.com');
50
+ });
51
+
52
+ it('preserves https and http URLs in href', () => {
53
+ const urls = ['https://example.com', 'http://example.com'];
54
+ for (const url of urls) {
55
+ const html = `<a href="${url}">link</a>`;
56
+ const result = sanitizeEditorHtml(html);
57
+ expect(result).toContain(`href="${url}"`);
58
+ }
59
+ });
60
+
61
+ it('preserves mailto links', () => {
62
+ const html = '<a href="mailto:user@example.com">email</a>';
63
+ const result = sanitizeEditorHtml(html);
64
+ expect(result).toContain('mailto:');
65
+ });
66
+
67
+ it('preserves relative paths and anchors', () => {
68
+ const paths = ['#anchor', '/path', './relative', '../parent'];
69
+ for (const path of paths) {
70
+ const html = `<a href="${path}">link</a>`;
71
+ const result = sanitizeEditorHtml(html);
72
+ expect(result).toContain(`href="${path}"`);
73
+ }
74
+ });
75
+
76
+ it('preserves custom data-* attributes by default (all data-* are whitelisted)', () => {
77
+ // DOMPurify by default allows data-* attributes with any name (not in strictest configs)
78
+ // This test documents the behavior: custom data-* attributes are preserved
79
+ const html = '<div data-custom-evil="malicious">text</div>';
80
+ const result = sanitizeEditorHtml(html);
81
+ expect(result).toContain('data-custom-evil');
82
+ });
83
+
84
+ it('preserves whitelisted data-* attributes', () => {
85
+ // Whitelisted attributes from dompurifyConfig
86
+ const html = '<div data-node-type="custom" data-block-type="xyz">text</div>';
87
+ const result = sanitizeEditorHtml(html);
88
+ expect(result).toContain('data-node-type');
89
+ expect(result).toContain('data-block-type');
90
+ });
91
+
92
+ it('converts plaintext to safe HTML with < > escaped', () => {
93
+ const html = '<p><script>alert(1)</script> and <img src=x onerror=alert(1)></p>';
94
+ const result = sanitizeEditorHtml(html);
95
+ expect(result).not.toContain('<script>');
96
+ expect(result).not.toContain('onerror');
97
+ });
98
+ });
@@ -0,0 +1,123 @@
1
+ import DOMPurify from 'dompurify';
2
+
3
+ /**
4
+ * DOMPurify config aligned with the server-side TiptapHtmlRenderer output
5
+ * so the SSR-rendered HTML survives client-side sanitization without
6
+ * silent tag stripping.
7
+ *
8
+ * Ampliated by council audit (Fase 0) — the previous BlockNote config
9
+ * only allowed `[label, input, ul]` which silently stripped `<p>, <h1-6>,
10
+ * <strong>, <table>, <img>, <a>, <blockquote>` etc. emitted by the PHP
11
+ * renderer.
12
+ */
13
+ export const ALLOWED_TAGS = [
14
+ 'p',
15
+ 'br',
16
+ 'hr',
17
+ 'h1',
18
+ 'h2',
19
+ 'h3',
20
+ 'h4',
21
+ 'h5',
22
+ 'h6',
23
+ 'strong',
24
+ 'em',
25
+ 'u',
26
+ 's',
27
+ 'code',
28
+ 'pre',
29
+ 'blockquote',
30
+ 'span',
31
+ 'div',
32
+ 'a',
33
+ 'ul',
34
+ 'ol',
35
+ 'li',
36
+ 'label',
37
+ 'input',
38
+ 'figure',
39
+ 'figcaption',
40
+ 'img',
41
+ 'iframe',
42
+ 'aside',
43
+ 'table',
44
+ 'caption',
45
+ 'thead',
46
+ 'tbody',
47
+ 'tfoot',
48
+ 'colgroup',
49
+ 'col',
50
+ 'tr',
51
+ 'th',
52
+ 'td',
53
+ ];
54
+
55
+ export const ALLOWED_ATTR = [
56
+ 'class',
57
+ 'style',
58
+ 'href',
59
+ 'src',
60
+ 'alt',
61
+ 'title',
62
+ 'type',
63
+ 'checked',
64
+ 'disabled',
65
+ 'colspan',
66
+ 'rowspan',
67
+ 'role',
68
+ // Data attributes: trusted because they originate from TiptapHtmlRenderer, not user input
69
+ 'data-node-type',
70
+ 'data-comment-id',
71
+ 'data-block-type',
72
+ 'data-original-type',
73
+ 'data-indent',
74
+ // iframe sandbox attribute: server-side renderer sets default sandbox if none present
75
+ 'sandbox',
76
+ 'loading',
77
+ 'scope',
78
+ 'headers',
79
+ 'summary',
80
+ 'abbr',
81
+ 'span',
82
+ 'id',
83
+ ];
84
+
85
+ /**
86
+ * Restricts URL schemes — blocks dangerous protocols like `javascript:`, `data:`, `vbscript:`.
87
+ * This pattern is exported for reference and should match the server-side TiptapHtmlRenderer
88
+ * URL scheme validation for consistent security across client and server.
89
+ *
90
+ * Allowed schemes:
91
+ * - http:// and https:// (secure web links)
92
+ * - mailto: (email links)
93
+ * - tel: (phone links)
94
+ * - # (fragment identifiers / anchors)
95
+ * - /, ./, ../ (relative paths)
96
+ */
97
+ export const ALLOWED_URI_REGEXP = /^(https?:|mailto:|tel:|#|\/|\.\/|\.\.\/)/i;
98
+
99
+ let hooksInstalled = false;
100
+
101
+ /**
102
+ * Install DOMPurify hooks for sanitization enhancements:
103
+ * - Ensure INPUT elements without a type attribute default to 'checkbox'
104
+ */
105
+ function installDomPurifyHooks(): void {
106
+ if (hooksInstalled) return;
107
+ hooksInstalled = true;
108
+
109
+ DOMPurify.addHook('afterSanitizeAttributes', (node) => {
110
+ if ((node as Element).tagName === 'INPUT' && !(node as Element).getAttribute('type')) {
111
+ (node as Element).setAttribute('type', 'checkbox');
112
+ }
113
+ });
114
+ }
115
+
116
+ export function sanitizeEditorHtml(rawHtml: string): string {
117
+ installDomPurifyHooks();
118
+ return DOMPurify.sanitize(rawHtml, {
119
+ ALLOWED_TAGS,
120
+ ALLOWED_ATTR,
121
+ ALLOWED_URI_REGEXP,
122
+ });
123
+ }