@ceedcv-maya/shared-editor-react 0.8.0 → 0.10.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.
- package/package.json +4 -1
- package/src/components/EditorContentJson.tsx +29 -0
- package/src/components/MayaEditor.tsx +83 -23
- package/src/index.ts +3 -0
- package/src/lib/looksLikeMarkdown.test.ts +36 -0
- package/src/lib/looksLikeMarkdown.ts +37 -0
- package/src/lib/markdownIngestion.test.ts +44 -0
- package/src/lib/markdownToHtml.test.ts +25 -0
- package/src/lib/markdownToHtml.ts +4 -1
- package/src/lib/renderTiptapJson.test.ts +82 -0
- package/src/lib/renderTiptapJson.ts +72 -0
- package/src/lib/tiptapContentSemantics.test.ts +180 -0
- package/src/lib/tiptapContentSemantics.ts +130 -5
- package/src/mammoth-browser.d.ts +3 -0
- package/src/parity/fingerprint.ts +75 -0
- package/src/parity/parity.test.ts +51 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ceedcv-maya/shared-editor-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"@tiptap/pm": "^3.0.0",
|
|
24
24
|
"@tiptap/react": "^3.0.0",
|
|
25
25
|
"@tiptap/starter-kit": "^3.0.0",
|
|
26
|
+
"@tiptap/static-renderer": "^3.0.0",
|
|
26
27
|
"dompurify": "^3.0.0",
|
|
27
28
|
"mammoth": "^1.8.0",
|
|
28
29
|
"react": "^18.0.0 || ^19.0.0",
|
|
@@ -34,7 +35,9 @@
|
|
|
34
35
|
}
|
|
35
36
|
},
|
|
36
37
|
"devDependencies": {
|
|
38
|
+
"@tiptap/static-renderer": "^3.0.0",
|
|
37
39
|
"@types/dompurify": "^3.0.0",
|
|
40
|
+
"@types/node": "^25.9.2",
|
|
38
41
|
"@types/react": "^19.0.0",
|
|
39
42
|
"@types/react-dom": "^19.0.0",
|
|
40
43
|
"jsdom": "^29.1.1",
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
|
|
3
|
+
import { renderTiptapJsonToHtml } from '../lib/renderTiptapJson';
|
|
4
|
+
import { EditorContentHtml } from './EditorContentHtml';
|
|
5
|
+
import type { TiptapDoc, TiptapNode } from '../types';
|
|
6
|
+
|
|
7
|
+
interface EditorContentJsonProps {
|
|
8
|
+
/**
|
|
9
|
+
* Stored TipTap content — a wrapped doc (`{ type: 'doc', content }`) or the
|
|
10
|
+
* bare content array that `MayaEditor` emits and the backend persists.
|
|
11
|
+
*/
|
|
12
|
+
content: TiptapDoc | TiptapNode[] | unknown;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Read-only renderer for stored TipTap JSON. Renders via TipTap's static
|
|
18
|
+
* renderer (single source of truth with the editor schema) and sanitises the
|
|
19
|
+
* result through `EditorContentHtml` (DOMPurify).
|
|
20
|
+
*
|
|
21
|
+
* Use this instead of hand-rolled JSON→HTML walkers. For pre-rendered HTML
|
|
22
|
+
* strings (e.g. server-side `TiptapHtmlRenderer` output) use
|
|
23
|
+
* `EditorContentHtml` directly.
|
|
24
|
+
*/
|
|
25
|
+
export function EditorContentJson({ content, className }: EditorContentJsonProps) {
|
|
26
|
+
const html = useMemo(() => renderTiptapJsonToHtml(content), [content]);
|
|
27
|
+
if (!html) return null;
|
|
28
|
+
return <EditorContentHtml html={html} className={className} />;
|
|
29
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react';
|
|
2
2
|
import { EditorContent, useEditor } from '@tiptap/react';
|
|
3
3
|
import type { Editor } from '@tiptap/react';
|
|
4
4
|
|
|
@@ -9,6 +9,7 @@ import { useEditorContent, type EditorOutput } from '../hooks/useEditorContent';
|
|
|
9
9
|
import { sanitizeEditorHtml } from '../lib/dompurifyConfig';
|
|
10
10
|
import { markdownToHtml } from '../lib/markdownToHtml';
|
|
11
11
|
import { htmlToMarkdown } from '../lib/htmlToMarkdown';
|
|
12
|
+
import { looksLikeMarkdown } from '../lib/looksLikeMarkdown';
|
|
12
13
|
import { normalizeTableHtml } from '../lib/normalizeTableHtml';
|
|
13
14
|
import { docxToHtml } from '../lib/docxToHtml';
|
|
14
15
|
import type { EditorMode, TiptapDoc } from '../types';
|
|
@@ -31,10 +32,15 @@ export interface MayaEditorProps {
|
|
|
31
32
|
/** Debounced change callback (300ms). Payload depends on `output`. */
|
|
32
33
|
onChange?: (payload: string | TiptapDoc) => void;
|
|
33
34
|
/**
|
|
34
|
-
* Llamado
|
|
35
|
-
*
|
|
35
|
+
* Llamado tras sincronizar el contenido (blur, cambio de bloque, destroy).
|
|
36
|
+
* Recibe el payload ya leído del editor; el padre suele enlazarlo a `forceSave`.
|
|
36
37
|
*/
|
|
37
|
-
onFlush?: () => void
|
|
38
|
+
onFlush?: (payload?: string | TiptapDoc) => void | Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Ref opcional para invocar flush+sync desde fuera (p. ej. antes de cambiar de bloque),
|
|
41
|
+
* evitando perder el último keystroke por el debounce de `onChange`.
|
|
42
|
+
*/
|
|
43
|
+
editorFlushRef?: MutableRefObject<(() => void | Promise<void>) | null>;
|
|
38
44
|
/**
|
|
39
45
|
* Output shape: `'html'` (default) emits a sanitisation-ready string;
|
|
40
46
|
* `'json'` emits the full ProseMirror doc `{type:'doc', content:[…]}`,
|
|
@@ -102,6 +108,7 @@ export function MayaEditor({
|
|
|
102
108
|
onExportDocx,
|
|
103
109
|
commentsById,
|
|
104
110
|
onFlush,
|
|
111
|
+
editorFlushRef,
|
|
105
112
|
}: MayaEditorProps) {
|
|
106
113
|
const effectiveOutput: EditorOutput = output ?? (mode === 'full' ? 'json' : 'html');
|
|
107
114
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
@@ -126,11 +133,19 @@ export function MayaEditor({
|
|
|
126
133
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
|
127
134
|
const onChangeRef = useRef(onChange);
|
|
128
135
|
const onFlushRef = useRef(onFlush);
|
|
136
|
+
const viewModeRef = useRef(viewMode);
|
|
137
|
+
const sourceTextRef = useRef(sourceText);
|
|
129
138
|
onChangeRef.current = onChange;
|
|
130
139
|
onFlushRef.current = onFlush;
|
|
140
|
+
viewModeRef.current = viewMode;
|
|
141
|
+
sourceTextRef.current = sourceText;
|
|
131
142
|
|
|
132
143
|
const extensions = useMemo(() => buildMayaEditorExtensions(mode), [mode]);
|
|
133
144
|
|
|
145
|
+
// Stable handle to the editor for use inside editorProps callbacks, which are
|
|
146
|
+
// defined before `useEditor` returns.
|
|
147
|
+
const editorRef = useRef<Editor | null>(null);
|
|
148
|
+
|
|
134
149
|
const editor = useEditor({
|
|
135
150
|
extensions,
|
|
136
151
|
content: initialContent ?? '',
|
|
@@ -143,30 +158,73 @@ export function MayaEditor({
|
|
|
143
158
|
// Reshape pasted HTML so complex tables (caption/tfoot/colgroup)
|
|
144
159
|
// survive TipTap's parser. See `normalizeTableHtml` for details.
|
|
145
160
|
transformPastedHTML: (html) => normalizeTableHtml(html),
|
|
161
|
+
// Plain-text paste that is actually Markdown is converted to structured
|
|
162
|
+
// nodes instead of being stored as a literal text node (which would show
|
|
163
|
+
// "## " / "**bold**" verbatim in previews). Rich paste carries text/html
|
|
164
|
+
// and is left to `transformPastedHTML` / the default handler.
|
|
165
|
+
handlePaste: (_view, event) => {
|
|
166
|
+
const cb = event.clipboardData;
|
|
167
|
+
if (!cb) return false;
|
|
168
|
+
if (cb.getData('text/html')) return false;
|
|
169
|
+
const text = cb.getData('text/plain');
|
|
170
|
+
if (!text || !looksLikeMarkdown(text)) return false;
|
|
171
|
+
const activeEditor = editorRef.current;
|
|
172
|
+
if (!activeEditor) return false;
|
|
173
|
+
const html = sanitizeEditorHtml(normalizeTableHtml(markdownToHtml(text)));
|
|
174
|
+
if (!html) return false;
|
|
175
|
+
activeEditor.commands.insertContent(html);
|
|
176
|
+
return true;
|
|
177
|
+
},
|
|
146
178
|
},
|
|
147
179
|
});
|
|
148
180
|
|
|
181
|
+
editorRef.current = editor;
|
|
182
|
+
|
|
149
183
|
useEditorContent(editor, onChange, { output: effectiveOutput });
|
|
150
184
|
|
|
151
|
-
const
|
|
152
|
-
if (!editor) return;
|
|
153
|
-
const handler = onChangeRef.current;
|
|
154
|
-
if (!handler) return;
|
|
185
|
+
const readPayloadFromEditor = useCallback((): string | TiptapDoc | undefined => {
|
|
186
|
+
if (!editor) return undefined;
|
|
155
187
|
const rawPayload =
|
|
156
188
|
effectiveOutput === 'json'
|
|
157
189
|
? (editor.getJSON() as TiptapDoc)
|
|
158
190
|
: editor.getHTML();
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
: rawPayload;
|
|
163
|
-
handler(payload);
|
|
191
|
+
return effectiveOutput === 'json'
|
|
192
|
+
? normalizeTiptapDocPayload(rawPayload)
|
|
193
|
+
: rawPayload;
|
|
164
194
|
}, [editor, effectiveOutput]);
|
|
165
195
|
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
196
|
+
const syncContentToParent = useCallback((): string | TiptapDoc | undefined => {
|
|
197
|
+
const payload = readPayloadFromEditor();
|
|
198
|
+
if (payload === undefined) return undefined;
|
|
199
|
+
onChangeRef.current?.(payload);
|
|
200
|
+
return payload;
|
|
201
|
+
}, [readPayloadFromEditor]);
|
|
202
|
+
|
|
203
|
+
const requestFlush = useCallback(async () => {
|
|
204
|
+
if (!editor) return;
|
|
205
|
+
|
|
206
|
+
const mode = viewModeRef.current;
|
|
207
|
+
if (mode !== 'wysiwyg') {
|
|
208
|
+
const rawHtml =
|
|
209
|
+
mode === 'markdown'
|
|
210
|
+
? markdownToHtml(sourceTextRef.current)
|
|
211
|
+
: sourceTextRef.current;
|
|
212
|
+
const html = sanitizeEditorHtml(normalizeTableHtml(rawHtml));
|
|
213
|
+
editor.commands.setContent(html, { emitUpdate: false });
|
|
214
|
+
setViewMode('wysiwyg');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const payload = syncContentToParent();
|
|
218
|
+
await onFlushRef.current?.(payload);
|
|
219
|
+
}, [editor, syncContentToParent]);
|
|
220
|
+
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
if (!editorFlushRef) return;
|
|
223
|
+
editorFlushRef.current = requestFlush;
|
|
224
|
+
return () => {
|
|
225
|
+
editorFlushRef.current = null;
|
|
226
|
+
};
|
|
227
|
+
}, [editorFlushRef, requestFlush]);
|
|
170
228
|
|
|
171
229
|
useEffect(() => {
|
|
172
230
|
if (editor && onEditorReady) onEditorReady(editor);
|
|
@@ -174,7 +232,9 @@ export function MayaEditor({
|
|
|
174
232
|
|
|
175
233
|
useEffect(() => {
|
|
176
234
|
if (!editor || !onFlush) return;
|
|
177
|
-
const onDestroy = () =>
|
|
235
|
+
const onDestroy = () => {
|
|
236
|
+
void requestFlush();
|
|
237
|
+
};
|
|
178
238
|
editor.on('destroy', onDestroy);
|
|
179
239
|
return () => {
|
|
180
240
|
editor.off('destroy', onDestroy);
|
|
@@ -306,8 +366,8 @@ export function MayaEditor({
|
|
|
306
366
|
.run();
|
|
307
367
|
};
|
|
308
368
|
|
|
309
|
-
const enterSource = (target: 'html' | 'markdown') => {
|
|
310
|
-
|
|
369
|
+
const enterSource = async (target: 'html' | 'markdown') => {
|
|
370
|
+
syncContentToParent();
|
|
311
371
|
const currentHtml = editor.getHTML();
|
|
312
372
|
const text = target === 'html' ? currentHtml : htmlToMarkdown(currentHtml);
|
|
313
373
|
setSourceText(text);
|
|
@@ -336,7 +396,7 @@ export function MayaEditor({
|
|
|
336
396
|
const html = sanitizeEditorHtml(markdownToHtml(sourceText));
|
|
337
397
|
setSourceText(html);
|
|
338
398
|
setViewMode('html');
|
|
339
|
-
} else enterSource('html');
|
|
399
|
+
} else void enterSource('html');
|
|
340
400
|
};
|
|
341
401
|
|
|
342
402
|
const toggleMarkdown = () => {
|
|
@@ -345,7 +405,7 @@ export function MayaEditor({
|
|
|
345
405
|
const md = htmlToMarkdown(sourceText);
|
|
346
406
|
setSourceText(md);
|
|
347
407
|
setViewMode('markdown');
|
|
348
|
-
} else enterSource('markdown');
|
|
408
|
+
} else void enterSource('markdown');
|
|
349
409
|
};
|
|
350
410
|
|
|
351
411
|
return (
|
|
@@ -356,7 +416,7 @@ export function MayaEditor({
|
|
|
356
416
|
if (!onFlush) return;
|
|
357
417
|
const next = e.relatedTarget as Node | null;
|
|
358
418
|
if (next && wrapperRef.current?.contains(next)) return;
|
|
359
|
-
requestFlush();
|
|
419
|
+
void requestFlush();
|
|
360
420
|
}}
|
|
361
421
|
>
|
|
362
422
|
{editorReady && (
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { MayaEditor } from './components/MayaEditor';
|
|
2
2
|
export type { MayaEditorProps } from './components/MayaEditor';
|
|
3
3
|
export { EditorContentHtml } from './components/EditorContentHtml';
|
|
4
|
+
export { EditorContentJson } from './components/EditorContentJson';
|
|
4
5
|
export { EditorToolbar } from './components/EditorToolbar';
|
|
5
6
|
export type { ToolbarLabels } from './components/EditorToolbar';
|
|
6
7
|
|
|
@@ -23,6 +24,7 @@ export { normalizeTableHtml } from './lib/normalizeTableHtml';
|
|
|
23
24
|
export { splitHtmlIntoBlocks } from './lib/splitHtmlIntoBlocks';
|
|
24
25
|
export type { BlockChunk, BlockChunkType } from './lib/splitHtmlIntoBlocks';
|
|
25
26
|
export { htmlToTiptapDoc } from './lib/htmlToTiptapDoc';
|
|
27
|
+
export { renderTiptapJsonToHtml } from './lib/renderTiptapJson';
|
|
26
28
|
export { buildMayaEditorExtensions } from './lib/editorExtensions';
|
|
27
29
|
export { isEditorReady } from './lib/isEditorReady';
|
|
28
30
|
export {
|
|
@@ -32,6 +34,7 @@ export {
|
|
|
32
34
|
isSemanticallyEmptyEditorHtml,
|
|
33
35
|
isSemanticallyEmptyTiptapContent,
|
|
34
36
|
normalizeTiptapContentForCompare,
|
|
37
|
+
normalizeTiptapContentForPersistence,
|
|
35
38
|
normalizeTiptapDocPayload,
|
|
36
39
|
tiptapContentEquals,
|
|
37
40
|
} from './lib/tiptapContentSemantics';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { looksLikeMarkdown } from './looksLikeMarkdown';
|
|
3
|
+
|
|
4
|
+
describe('looksLikeMarkdown', () => {
|
|
5
|
+
it('detects ATX headings', () => {
|
|
6
|
+
expect(looksLikeMarkdown('## Programa del curso')).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('detects ordered and unordered lists', () => {
|
|
10
|
+
expect(looksLikeMarkdown('1. **Introducción a Laravel**')).toBe(true);
|
|
11
|
+
expect(looksLikeMarkdown('- item uno\n- item dos')).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('detects fenced code and blockquotes and tables', () => {
|
|
15
|
+
expect(looksLikeMarkdown('```\ncode\n```')).toBe(true);
|
|
16
|
+
expect(looksLikeMarkdown('> cita')).toBe(true);
|
|
17
|
+
expect(looksLikeMarkdown('| a | b |\n| --- | --- |')).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('does NOT flag intentional pipe text without a delimiter row', () => {
|
|
21
|
+
expect(looksLikeMarkdown('| Total 1º ASIR | 30 | 1000 | 265')).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('detects inline bold/code/links', () => {
|
|
25
|
+
expect(looksLikeMarkdown('texto con **negrita** dentro')).toBe(true);
|
|
26
|
+
expect(looksLikeMarkdown('usa `composer install` aquí')).toBe(true);
|
|
27
|
+
expect(looksLikeMarkdown('ver [docs](https://x.y)')).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('leaves ordinary prose untouched', () => {
|
|
31
|
+
expect(looksLikeMarkdown('Esto es un párrafo normal sin formato.')).toBe(false);
|
|
32
|
+
expect(looksLikeMarkdown('Precio: 5 * 3 = 15')).toBe(false);
|
|
33
|
+
expect(looksLikeMarkdown('C# y F# son lenguajes')).toBe(false);
|
|
34
|
+
expect(looksLikeMarkdown('')).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heuristic: does this plain text contain Markdown syntax worth converting to
|
|
3
|
+
* structured nodes?
|
|
4
|
+
*
|
|
5
|
+
* Used by the editor paste handler (convert pasted Markdown → TipTap nodes
|
|
6
|
+
* instead of storing it as a literal text node) and mirrored by the backend
|
|
7
|
+
* data-repair detection. Kept conservative so ordinary prose is NOT rewritten:
|
|
8
|
+
* a single stray `*` or `#` is ignored; we require a real block marker at the
|
|
9
|
+
* start of a line or an unambiguous inline pair.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const BLOCK_PATTERNS: RegExp[] = [
|
|
13
|
+
/^#{1,6}\s+\S/m, // ATX heading: "## Title"
|
|
14
|
+
/^\s*[-*+]\s+\S/m, // unordered list item: "- item"
|
|
15
|
+
/^\s*\d+\.\s+\S/m, // ordered list item: "1. item"
|
|
16
|
+
/^\s*>\s+\S/m, // blockquote: "> quote"
|
|
17
|
+
/^```/m, // fenced code block
|
|
18
|
+
// GFM table: require a delimiter row (|---|), so intentional pipe text like
|
|
19
|
+
// "| Total | 30 |" (no delimiter) is NOT flagged as markdown.
|
|
20
|
+
/\|\s*:?-{3,}|-{3,}:?\s*\|/,
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const INLINE_PATTERNS: RegExp[] = [
|
|
24
|
+
/\*\*[^\s*][^*]*\*\*/, // **bold**
|
|
25
|
+
/__[^\s_][^_]*__/, // __bold__
|
|
26
|
+
/~~[^\s~][^~]*~~/, // ~~strike~~
|
|
27
|
+
/`[^`\n]+`/, // `inline code`
|
|
28
|
+
/\[[^\]]+\]\([^)\s]+\)/, // [text](url)
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export function looksLikeMarkdown(text: string): boolean {
|
|
32
|
+
if (!text || text.length < 2) return false;
|
|
33
|
+
if (BLOCK_PATTERNS.some((re) => re.test(text))) return true;
|
|
34
|
+
// Require an inline marker to actually be present; a lone "*" won't match the
|
|
35
|
+
// paired patterns above, so prose stays untouched.
|
|
36
|
+
return INLINE_PATTERNS.some((re) => re.test(text));
|
|
37
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards the paste/ingestion contract used by MayaEditor's `handlePaste`:
|
|
3
|
+
* Markdown text → HTML (markdownToHtml) → TipTap nodes (htmlToTiptapDoc) must
|
|
4
|
+
* yield STRUCTURED nodes, never a single literal text node. This is the fix for
|
|
5
|
+
* the "## " / "**bold**" rendered verbatim bug (a data/ingestion problem).
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect } from 'vitest';
|
|
8
|
+
import { markdownToHtml } from './markdownToHtml';
|
|
9
|
+
import { htmlToTiptapDoc } from './htmlToTiptapDoc';
|
|
10
|
+
import { looksLikeMarkdown } from './looksLikeMarkdown';
|
|
11
|
+
|
|
12
|
+
function ingest(md: string) {
|
|
13
|
+
return htmlToTiptapDoc(markdownToHtml(md));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('markdown ingestion pipeline', () => {
|
|
17
|
+
it('turns a heading line into a heading node, not literal "## "', () => {
|
|
18
|
+
const md = '## Programa del curso';
|
|
19
|
+
expect(looksLikeMarkdown(md)).toBe(true);
|
|
20
|
+
const doc = ingest(md);
|
|
21
|
+
const heading = doc.content.find((n) => n.type === 'heading');
|
|
22
|
+
expect(heading).toBeTruthy();
|
|
23
|
+
expect(heading?.attrs?.level).toBe(2);
|
|
24
|
+
const text = JSON.stringify(doc);
|
|
25
|
+
expect(text).not.toContain('## Programa');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('turns an ordered list with bold into list + strong nodes', () => {
|
|
29
|
+
const doc = ingest('1. **Introducción a Laravel 13.**');
|
|
30
|
+
const list = doc.content.find((n) => n.type === 'orderedList');
|
|
31
|
+
expect(list).toBeTruthy();
|
|
32
|
+
const json = JSON.stringify(doc);
|
|
33
|
+
expect(json).toContain('"bold"');
|
|
34
|
+
expect(json).not.toContain('**Introducción');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('turns inline bold into a strong node', () => {
|
|
38
|
+
const doc = ingest('CICLO FORMATIVO DE **NOMBRE_DEL_CICLO**');
|
|
39
|
+
const json = JSON.stringify(doc);
|
|
40
|
+
expect(json).toContain('"bold"');
|
|
41
|
+
expect(json).toContain('NOMBRE_DEL_CICLO');
|
|
42
|
+
expect(json).not.toContain('**NOMBRE_DEL_CICLO**');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { markdownToHtml } from './markdownToHtml';
|
|
3
|
+
|
|
4
|
+
describe('markdownToHtml', () => {
|
|
5
|
+
it('bolds **text** and emphasises *text*', () => {
|
|
6
|
+
expect(markdownToHtml('**b**')).toContain('<strong>b</strong>');
|
|
7
|
+
expect(markdownToHtml('*i*')).toContain('<em>i</em>');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('does NOT emphasise intra-word underscores (CommonMark)', () => {
|
|
11
|
+
// Regression: placeholders like NOMBRE_DEL_CICLO must survive intact.
|
|
12
|
+
const html = markdownToHtml('CICLO DE **NOMBRE_DEL_CICLO**');
|
|
13
|
+
expect(html).toContain('NOMBRE_DEL_CICLO');
|
|
14
|
+
expect(html).not.toContain('<em>DEL</em>');
|
|
15
|
+
expect(html).toContain('<strong>NOMBRE_DEL_CICLO</strong>');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('still emphasises standalone _word_', () => {
|
|
19
|
+
expect(markdownToHtml('hola _mundo_ chau')).toContain('<em>mundo</em>');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('renders ATX headings', () => {
|
|
23
|
+
expect(markdownToHtml('## Programa')).toMatch(/<h2[^>]*>Programa<\/h2>/);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -43,7 +43,10 @@ function applyInline(text: string): string {
|
|
|
43
43
|
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
44
44
|
out = out.replace(/__([^_]+)__/g, '<strong>$1</strong>');
|
|
45
45
|
out = out.replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>');
|
|
46
|
-
|
|
46
|
+
// Underscore emphasis must not fire intra-word (CommonMark): `a_b_c` stays
|
|
47
|
+
// literal, so placeholders like `NOMBRE_DEL_CICLO` survive. Require a
|
|
48
|
+
// non-word boundary on both sides.
|
|
49
|
+
out = out.replace(/(?<![\w_])_([^_\n]+?)_(?![\w_])/g, '<em>$1</em>');
|
|
47
50
|
// Strikethrough.
|
|
48
51
|
out = out.replace(/~~([^~]+)~~/g, '<s>$1</s>');
|
|
49
52
|
// Links — only http/https/mailto/tel; reject `javascript:` etc.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { renderTiptapJsonToHtml } from './renderTiptapJson';
|
|
3
|
+
import type { TiptapDoc } from '../types';
|
|
4
|
+
|
|
5
|
+
describe('renderTiptapJsonToHtml', () => {
|
|
6
|
+
it('renders a wrapped doc', () => {
|
|
7
|
+
const doc: TiptapDoc = {
|
|
8
|
+
type: 'doc',
|
|
9
|
+
content: [
|
|
10
|
+
{ type: 'paragraph', content: [{ type: 'text', text: 'hola' }] },
|
|
11
|
+
],
|
|
12
|
+
};
|
|
13
|
+
const html = renderTiptapJsonToHtml(doc);
|
|
14
|
+
expect(html).toContain('<p');
|
|
15
|
+
expect(html).toContain('hola');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('accepts a bare content array (the shape MayaEditor emits)', () => {
|
|
19
|
+
const html = renderTiptapJsonToHtml([
|
|
20
|
+
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Sección' }] },
|
|
21
|
+
]);
|
|
22
|
+
expect(html).toMatch(/<h2[^>]*>.*Sección.*<\/h2>/s);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('renders bold marks as <strong>', () => {
|
|
26
|
+
const html = renderTiptapJsonToHtml([
|
|
27
|
+
{
|
|
28
|
+
type: 'paragraph',
|
|
29
|
+
content: [
|
|
30
|
+
{ type: 'text', text: 'plain ' },
|
|
31
|
+
{ type: 'text', text: 'bold', marks: [{ type: 'bold' }] },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
]);
|
|
35
|
+
expect(html).toMatch(/<strong[^>]*>bold<\/strong>/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('renders a code block without interpreting markdown inside it', () => {
|
|
39
|
+
const html = renderTiptapJsonToHtml([
|
|
40
|
+
{ type: 'codeBlock', content: [{ type: 'text', text: '## not a heading' }] },
|
|
41
|
+
]);
|
|
42
|
+
expect(html).toContain('<pre');
|
|
43
|
+
expect(html).toContain('## not a heading');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('does NOT turn literal markdown text into formatting (data, not render)', () => {
|
|
47
|
+
// Regression guard: markdown stored as plain text must render literally —
|
|
48
|
+
// this is why the fix lives at ingestion, not in the renderer.
|
|
49
|
+
const html = renderTiptapJsonToHtml([
|
|
50
|
+
{ type: 'paragraph', content: [{ type: 'text', text: '## Programa **negrita**' }] },
|
|
51
|
+
]);
|
|
52
|
+
expect(html).toContain('## Programa **negrita**');
|
|
53
|
+
expect(html).not.toContain('<h2');
|
|
54
|
+
expect(html).not.toContain('<strong>');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('renders a table', () => {
|
|
58
|
+
const html = renderTiptapJsonToHtml([
|
|
59
|
+
{
|
|
60
|
+
type: 'table',
|
|
61
|
+
content: [
|
|
62
|
+
{
|
|
63
|
+
type: 'tableRow',
|
|
64
|
+
content: [
|
|
65
|
+
{ type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }] },
|
|
66
|
+
{ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] }] },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
]);
|
|
72
|
+
expect(html).toContain('<table');
|
|
73
|
+
expect(html).toContain('A');
|
|
74
|
+
expect(html).toContain('B');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('returns empty string for null/empty content', () => {
|
|
78
|
+
expect(renderTiptapJsonToHtml(null)).toBe('');
|
|
79
|
+
expect(renderTiptapJsonToHtml([])).toBe('');
|
|
80
|
+
expect(renderTiptapJsonToHtml(undefined)).toBe('');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a stored TipTap/ProseMirror JSON document to an HTML string using
|
|
3
|
+
* TipTap's official static renderer.
|
|
4
|
+
*
|
|
5
|
+
* This replaces hand-rolled JSON→HTML walkers (which drift from the live
|
|
6
|
+
* editor and from the server-side `TiptapHtmlRenderer.php`). The static
|
|
7
|
+
* renderer builds the schema from the canonical extension set and runs each
|
|
8
|
+
* extension's `renderHTML`, so custom nodes (AlertBlock, IframeBlock, …) and
|
|
9
|
+
* marks render exactly as the editor defines them — a single source of truth
|
|
10
|
+
* on the JS side.
|
|
11
|
+
*
|
|
12
|
+
* The returned HTML is NOT sanitised; pass it through `EditorContentHtml`
|
|
13
|
+
* (DOMPurify) or use the `EditorContentJson` component for read-only views.
|
|
14
|
+
*
|
|
15
|
+
* Accepts both shapes persisted by the platform:
|
|
16
|
+
* - a wrapped doc `{ type: 'doc', content: [...] }`
|
|
17
|
+
* - a bare content array (`MayaEditor` emits `onChange(doc.content)`)
|
|
18
|
+
*
|
|
19
|
+
* Static-renderer caveats (per TipTap docs): no editor instance is created, so
|
|
20
|
+
* `addProseMirrorPlugins`/`onCreate`/transaction hooks do not run, and the
|
|
21
|
+
* output can differ slightly from the live editor for nodeView-backed nodes.
|
|
22
|
+
*/
|
|
23
|
+
import { renderToHTMLString } from '@tiptap/static-renderer';
|
|
24
|
+
import type { Extensions } from '@tiptap/core';
|
|
25
|
+
|
|
26
|
+
import { buildMayaEditorExtensions } from './editorExtensions';
|
|
27
|
+
import type { TiptapDoc, TiptapNode } from '../types';
|
|
28
|
+
|
|
29
|
+
function looksLikeTiptapDoc(value: unknown): value is TiptapDoc {
|
|
30
|
+
return (
|
|
31
|
+
!!value &&
|
|
32
|
+
typeof value === 'object' &&
|
|
33
|
+
!Array.isArray(value) &&
|
|
34
|
+
(value as { type?: unknown }).type === 'doc' &&
|
|
35
|
+
Array.isArray((value as { content?: unknown }).content)
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function looksLikeTiptapContentArray(value: unknown): value is TiptapNode[] {
|
|
40
|
+
return (
|
|
41
|
+
Array.isArray(value) &&
|
|
42
|
+
value.length > 0 &&
|
|
43
|
+
value.every((n) => !!n && typeof n === 'object')
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Normalise the accepted input shapes to a wrapped doc, or `null` if empty. */
|
|
48
|
+
function toDoc(content: unknown): TiptapDoc | null {
|
|
49
|
+
if (content == null) return null;
|
|
50
|
+
if (looksLikeTiptapDoc(content)) return content;
|
|
51
|
+
if (looksLikeTiptapContentArray(content)) {
|
|
52
|
+
return { type: 'doc', content };
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function renderTiptapJsonToHtml(
|
|
58
|
+
content: TiptapDoc | TiptapNode[] | unknown,
|
|
59
|
+
extensions?: Extensions,
|
|
60
|
+
): string {
|
|
61
|
+
const doc = toDoc(content);
|
|
62
|
+
if (!doc) return '';
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
return renderToHTMLString({
|
|
66
|
+
content: doc,
|
|
67
|
+
extensions: extensions ?? buildMayaEditorExtensions('full'),
|
|
68
|
+
});
|
|
69
|
+
} catch {
|
|
70
|
+
return '';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
canonicalTiptapContentJson,
|
|
4
4
|
isSemanticallyEmptyTiptapContent,
|
|
5
5
|
normalizeTiptapContentForCompare,
|
|
6
|
+
normalizeTiptapContentForPersistence,
|
|
6
7
|
tiptapContentEquals,
|
|
7
8
|
} from './tiptapContentSemantics';
|
|
8
9
|
|
|
@@ -33,4 +34,183 @@ describe('tiptapContentSemantics', () => {
|
|
|
33
34
|
const nodes = [{ type: 'image', attrs: { src: 'https://example.com/x.png' } }];
|
|
34
35
|
expect(isSemanticallyEmptyTiptapContent(nodes)).toBe(false);
|
|
35
36
|
});
|
|
37
|
+
|
|
38
|
+
it('ignores table colwidth attrs added by the editor on open', () => {
|
|
39
|
+
const fromTemplate = [
|
|
40
|
+
{
|
|
41
|
+
type: 'table',
|
|
42
|
+
content: [
|
|
43
|
+
{
|
|
44
|
+
type: 'tableRow',
|
|
45
|
+
content: [
|
|
46
|
+
{
|
|
47
|
+
type: 'tableCell',
|
|
48
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }],
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
const afterEditor = [
|
|
56
|
+
{
|
|
57
|
+
type: 'table',
|
|
58
|
+
content: [
|
|
59
|
+
{
|
|
60
|
+
type: 'tableRow',
|
|
61
|
+
content: [
|
|
62
|
+
{
|
|
63
|
+
type: 'tableCell',
|
|
64
|
+
attrs: { colwidth: [120] },
|
|
65
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }],
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
expect(tiptapContentEquals(fromTemplate, afterEditor)).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('ignores volatile image attrs added by the editor on open', () => {
|
|
76
|
+
const fromTemplate = [{ type: 'image', attrs: { src: 'https://example.com/x.png', alt: 'Logo' } }];
|
|
77
|
+
const afterEditor = [{
|
|
78
|
+
type: 'image',
|
|
79
|
+
attrs: {
|
|
80
|
+
src: 'https://example.com/x.png',
|
|
81
|
+
alt: 'Logo',
|
|
82
|
+
width: 400,
|
|
83
|
+
height: 200,
|
|
84
|
+
class: 'ProseMirror-selectednode',
|
|
85
|
+
},
|
|
86
|
+
}];
|
|
87
|
+
expect(tiptapContentEquals(fromTemplate, afterEditor)).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('treats tableHeader cells as tableCell for template parity', () => {
|
|
91
|
+
const fromTemplate = [{
|
|
92
|
+
type: 'table',
|
|
93
|
+
content: [{
|
|
94
|
+
type: 'tableRow',
|
|
95
|
+
content: [{
|
|
96
|
+
type: 'tableCell',
|
|
97
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }],
|
|
98
|
+
}],
|
|
99
|
+
}],
|
|
100
|
+
}];
|
|
101
|
+
const afterEditor = [{
|
|
102
|
+
type: 'table',
|
|
103
|
+
content: [{
|
|
104
|
+
type: 'tableRow',
|
|
105
|
+
content: [{
|
|
106
|
+
type: 'tableHeader',
|
|
107
|
+
attrs: { colwidth: [90] },
|
|
108
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }],
|
|
109
|
+
}],
|
|
110
|
+
}],
|
|
111
|
+
}];
|
|
112
|
+
expect(tiptapContentEquals(fromTemplate, afterEditor)).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('does not treat paragraph with nested image as semantically empty', () => {
|
|
116
|
+
const nodes = [{
|
|
117
|
+
type: 'paragraph',
|
|
118
|
+
content: [{ type: 'image', attrs: { src: 'https://example.com/x.png' } }],
|
|
119
|
+
}];
|
|
120
|
+
expect(isSemanticallyEmptyTiptapContent(nodes)).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('preserves heading level and text for persistence', () => {
|
|
124
|
+
const heading = [{
|
|
125
|
+
type: 'heading',
|
|
126
|
+
attrs: { level: 1 },
|
|
127
|
+
content: [{ type: 'text', text: 'Título H1' }],
|
|
128
|
+
}];
|
|
129
|
+
expect(normalizeTiptapContentForPersistence(heading)).toEqual(heading);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('compare normalization keeps text on inline nodes', () => {
|
|
133
|
+
const nodes = [{
|
|
134
|
+
type: 'heading',
|
|
135
|
+
attrs: { level: 1 },
|
|
136
|
+
content: [{ type: 'text', text: 'Hola' }],
|
|
137
|
+
}];
|
|
138
|
+
expect(normalizeTiptapContentForCompare(nodes)).toEqual(nodes);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('ignores phantom empty paragraphs inside table cells', () => {
|
|
142
|
+
const template = [{
|
|
143
|
+
type: 'table',
|
|
144
|
+
content: [{
|
|
145
|
+
type: 'tableRow',
|
|
146
|
+
content: [{
|
|
147
|
+
type: 'tableCell',
|
|
148
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'c' }] }],
|
|
149
|
+
}],
|
|
150
|
+
}],
|
|
151
|
+
}];
|
|
152
|
+
const afterEditor = [{
|
|
153
|
+
type: 'table',
|
|
154
|
+
content: [{
|
|
155
|
+
type: 'tableRow',
|
|
156
|
+
content: [{
|
|
157
|
+
type: 'tableCell',
|
|
158
|
+
attrs: { colwidth: [100] },
|
|
159
|
+
content: [
|
|
160
|
+
{ type: 'paragraph', content: [{ type: 'text', text: 'c' }] },
|
|
161
|
+
{ type: 'paragraph', content: [] },
|
|
162
|
+
],
|
|
163
|
+
}],
|
|
164
|
+
}],
|
|
165
|
+
}];
|
|
166
|
+
expect(tiptapContentEquals(template, afterEditor)).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('ignores empty list items added by pressing Enter without content', () => {
|
|
170
|
+
const template = [{
|
|
171
|
+
type: 'bulletList',
|
|
172
|
+
content: [{
|
|
173
|
+
type: 'listItem',
|
|
174
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Uno' }] }],
|
|
175
|
+
}],
|
|
176
|
+
}];
|
|
177
|
+
const afterEditor = [{
|
|
178
|
+
type: 'bulletList',
|
|
179
|
+
content: [
|
|
180
|
+
{
|
|
181
|
+
type: 'listItem',
|
|
182
|
+
content: [{ type: 'paragraph', content: [] }],
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
type: 'listItem',
|
|
186
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Uno' }] }],
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
}];
|
|
190
|
+
expect(tiptapContentEquals(template, afterEditor)).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('ignores trailing empty list item at end of list', () => {
|
|
194
|
+
const template = [{
|
|
195
|
+
type: 'bulletList',
|
|
196
|
+
content: [{
|
|
197
|
+
type: 'listItem',
|
|
198
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Uno' }] }],
|
|
199
|
+
}],
|
|
200
|
+
}];
|
|
201
|
+
const afterEditor = [{
|
|
202
|
+
type: 'bulletList',
|
|
203
|
+
content: [
|
|
204
|
+
{
|
|
205
|
+
type: 'listItem',
|
|
206
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Uno' }] }],
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
type: 'listItem',
|
|
210
|
+
content: [{ type: 'paragraph', content: [] }],
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
}];
|
|
214
|
+
expect(tiptapContentEquals(template, afterEditor)).toBe(true);
|
|
215
|
+
});
|
|
36
216
|
});
|
|
@@ -14,7 +14,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
function asNode(value: unknown): TiptapNode | null {
|
|
17
|
-
return isRecord(value) && typeof value.type === 'string' ? (value as TiptapNode) : null;
|
|
17
|
+
return isRecord(value) && typeof value.type === 'string' ? (value as unknown as TiptapNode) : null;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
function inlineTextLength(nodes: unknown): number {
|
|
@@ -36,6 +36,29 @@ function inlineTextLength(nodes: unknown): number {
|
|
|
36
36
|
return length;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/** True when inline/block children carry text or non-empty media (e.g. image in paragraph). */
|
|
40
|
+
function blockChildrenHaveMeaningfulContent(nodes: unknown): boolean {
|
|
41
|
+
if (!Array.isArray(nodes)) return false;
|
|
42
|
+
|
|
43
|
+
for (const raw of nodes) {
|
|
44
|
+
const node = asNode(raw);
|
|
45
|
+
if (!node) continue;
|
|
46
|
+
if (node.type === 'text' && typeof node.text === 'string') {
|
|
47
|
+
if (node.text.replace(/\u00a0/g, ' ').trim().length > 0) return true;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (node.type === 'hardBreak') continue;
|
|
51
|
+
if (MEANINGFUL_BLOCK_TYPES.has(node.type) && !isEmptyTiptapBlockNode(node)) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
if (Array.isArray(node.content) && blockChildrenHaveMeaningfulContent(node.content)) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
39
62
|
/** True for TipTap/BlockNote blocks with no visible text and no embedded media. */
|
|
40
63
|
export function isEmptyTiptapBlockNode(node: unknown): boolean {
|
|
41
64
|
const n = asNode(node);
|
|
@@ -68,7 +91,7 @@ export function isEmptyTiptapBlockNode(node: unknown): boolean {
|
|
|
68
91
|
}
|
|
69
92
|
|
|
70
93
|
// paragraph, heading, legacy BlockNote blocks, etc.
|
|
71
|
-
return
|
|
94
|
+
return !blockChildrenHaveMeaningfulContent(n.content ?? []);
|
|
72
95
|
}
|
|
73
96
|
|
|
74
97
|
function toContentArray(value: unknown): unknown[] {
|
|
@@ -80,11 +103,101 @@ function toContentArray(value: unknown): unknown[] {
|
|
|
80
103
|
return [];
|
|
81
104
|
}
|
|
82
105
|
|
|
106
|
+
/** Atributos que TipTap añade al abrir/guardar y no representan edición del usuario. */
|
|
107
|
+
const VOLATILE_NODE_ATTR_KEYS = new Set([
|
|
108
|
+
'colwidth',
|
|
109
|
+
'columnSizing',
|
|
110
|
+
'data-colwidth',
|
|
111
|
+
'width',
|
|
112
|
+
'height',
|
|
113
|
+
'style',
|
|
114
|
+
'class',
|
|
115
|
+
'title',
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
const IMAGE_COMPARE_ATTR_KEYS = ['src', 'alt'] as const;
|
|
119
|
+
|
|
120
|
+
function stripTrailingEmptyBlocks(nodes: unknown[]): unknown[] {
|
|
121
|
+
const out = JSON.parse(JSON.stringify(nodes));
|
|
122
|
+
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function canonicalizeNodeAttrs(type: string, rawAttrs: unknown): Record<string, unknown> | undefined {
|
|
127
|
+
if (!isRecord(rawAttrs)) return undefined;
|
|
128
|
+
|
|
129
|
+
let attrs: Record<string, unknown> = { ...rawAttrs };
|
|
130
|
+
if (type === 'image') {
|
|
131
|
+
const picked: Record<string, unknown> = {};
|
|
132
|
+
for (const key of IMAGE_COMPARE_ATTR_KEYS) {
|
|
133
|
+
const value = attrs[key];
|
|
134
|
+
if (value != null && String(value).trim() !== '') {
|
|
135
|
+
picked[key] = value;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return Object.keys(picked).length > 0 ? picked : undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (const key of VOLATILE_NODE_ATTR_KEYS) {
|
|
142
|
+
delete attrs[key];
|
|
143
|
+
}
|
|
144
|
+
if (type === 'tableCell' && attrs.colspan === 1) delete attrs.colspan;
|
|
145
|
+
if (type === 'tableCell' && attrs.rowspan === 1) delete attrs.rowspan;
|
|
146
|
+
|
|
147
|
+
return Object.keys(attrs).length > 0 ? attrs : undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Canonicalize and drop phantom empty nodes at every depth (cells, list items, etc.). */
|
|
151
|
+
function canonicalizeNodeForCompare(node: unknown): unknown | null {
|
|
152
|
+
const n = asNode(node);
|
|
153
|
+
if (!n?.type) return null;
|
|
154
|
+
|
|
155
|
+
if (n.type === 'text') {
|
|
156
|
+
const text = typeof n.text === 'string' ? n.text : '';
|
|
157
|
+
const hasMarks = Array.isArray(n.marks) && n.marks.length > 0;
|
|
158
|
+
if (text.replace(/\u00a0/g, ' ').trim() === '' && !hasMarks) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
type: 'text',
|
|
163
|
+
...(typeof n.text === 'string' ? { text: n.text } : {}),
|
|
164
|
+
...(hasMarks ? { marks: n.marks } : {}),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// TipTap may emit tableHeader where the template stored tableCell.
|
|
169
|
+
const rawType = n.type;
|
|
170
|
+
const type = rawType === 'tableHeader' ? 'tableCell' : rawType;
|
|
171
|
+
const attrs = canonicalizeNodeAttrs(type, n.attrs);
|
|
172
|
+
|
|
173
|
+
let content: unknown[] | undefined;
|
|
174
|
+
if (Array.isArray(n.content)) {
|
|
175
|
+
let children = n.content
|
|
176
|
+
.map((child) => canonicalizeNodeForCompare(child))
|
|
177
|
+
.filter((child): child is unknown => child != null);
|
|
178
|
+
|
|
179
|
+
if (rawType === 'bulletList' || rawType === 'orderedList' || rawType === 'taskList') {
|
|
180
|
+
children = children.filter((child) => !isEmptyTiptapBlockNode(child));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
children = stripTrailingEmptyBlocks(children);
|
|
184
|
+
content = children.length > 0 ? children : undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const out = {
|
|
188
|
+
type,
|
|
189
|
+
...(attrs ? { attrs } : {}),
|
|
190
|
+
...(content !== undefined ? { content } : {}),
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
return isEmptyTiptapBlockNode(out) ? null : out;
|
|
194
|
+
}
|
|
195
|
+
|
|
83
196
|
/**
|
|
84
197
|
* Strips trailing empty paragraphs TipTap adds for cursor placement.
|
|
85
|
-
*
|
|
198
|
+
* Preserves the full node tree (text, marks, heading levels) for persistence.
|
|
86
199
|
*/
|
|
87
|
-
export function
|
|
200
|
+
export function normalizeTiptapContentForPersistence(value: unknown): unknown[] {
|
|
88
201
|
const nodes = [...toContentArray(value)];
|
|
89
202
|
while (nodes.length > 0 && isEmptyTiptapBlockNode(nodes[nodes.length - 1])) {
|
|
90
203
|
nodes.pop();
|
|
@@ -92,6 +205,18 @@ export function normalizeTiptapContentForCompare(value: unknown): unknown[] {
|
|
|
92
205
|
return nodes;
|
|
93
206
|
}
|
|
94
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Canonical form for semantic diff/compare (strips volatile attrs, trailing empties).
|
|
210
|
+
* Returns a new array; does not mutate the input.
|
|
211
|
+
*/
|
|
212
|
+
export function normalizeTiptapContentForCompare(value: unknown): unknown[] {
|
|
213
|
+
const nodes = toContentArray(value)
|
|
214
|
+
.map((node) => canonicalizeNodeForCompare(node))
|
|
215
|
+
.filter((node): node is unknown => node != null);
|
|
216
|
+
|
|
217
|
+
return stripTrailingEmptyBlocks(nodes);
|
|
218
|
+
}
|
|
219
|
+
|
|
95
220
|
/**
|
|
96
221
|
* True when content has no meaningful text or media (e.g. only `<p></p>` / `paragraph: []`).
|
|
97
222
|
*/
|
|
@@ -136,6 +261,6 @@ export function normalizeTiptapDocPayload(payload: string | TiptapDoc): string |
|
|
|
136
261
|
if (typeof payload === 'string') return payload;
|
|
137
262
|
return {
|
|
138
263
|
...payload,
|
|
139
|
-
content:
|
|
264
|
+
content: normalizeTiptapContentForPersistence(payload.content) as TiptapDoc['content'],
|
|
140
265
|
};
|
|
141
266
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic fingerprint of rendered editor HTML, used by the renderer parity
|
|
3
|
+
* oracle. MUST stay logically identical to the PHP version in
|
|
4
|
+
* `shared-editor-laravel/tests/Support/Fingerprint.php`.
|
|
5
|
+
*
|
|
6
|
+
* Captures content + structure (what must agree between the CSR static renderer
|
|
7
|
+
* and the server-side `TiptapHtmlRenderer`), deliberately ignoring cosmetic
|
|
8
|
+
* markup (wrappers, data-* attributes, inline-style formatting).
|
|
9
|
+
*/
|
|
10
|
+
export interface HtmlFingerprint {
|
|
11
|
+
text: string;
|
|
12
|
+
headings: number[];
|
|
13
|
+
links: string[];
|
|
14
|
+
images: string[];
|
|
15
|
+
strong: number;
|
|
16
|
+
em: number;
|
|
17
|
+
u: number;
|
|
18
|
+
s: number;
|
|
19
|
+
code: number;
|
|
20
|
+
ul: number;
|
|
21
|
+
ol: number;
|
|
22
|
+
li: number;
|
|
23
|
+
pre: number;
|
|
24
|
+
blockquote: number;
|
|
25
|
+
hr: number;
|
|
26
|
+
table: number;
|
|
27
|
+
tr: number;
|
|
28
|
+
th: number;
|
|
29
|
+
td: number;
|
|
30
|
+
aside: number;
|
|
31
|
+
checkbox: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function fingerprintHtml(html: string): HtmlFingerprint {
|
|
35
|
+
const doc = new DOMParser().parseFromString(`<div id="__root">${html}</div>`, 'text/html');
|
|
36
|
+
const root = doc.getElementById('__root');
|
|
37
|
+
if (!root) {
|
|
38
|
+
throw new Error('fingerprintHtml: could not parse HTML');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const count = (sel: string): number => root.querySelectorAll(sel).length;
|
|
42
|
+
const attrs = (sel: string, attr: string): string[] =>
|
|
43
|
+
Array.from(root.querySelectorAll(sel))
|
|
44
|
+
.map((el) => el.getAttribute(attr) ?? '')
|
|
45
|
+
.sort();
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
// Whitespace removed entirely: inter-element spacing (e.g. the space PHP
|
|
49
|
+
// emits after a task checkbox, or block concatenation) is cosmetic and must
|
|
50
|
+
// not break parity — only the sequence of visible characters matters.
|
|
51
|
+
text: (root.textContent ?? '').replace(/\s+/g, ''),
|
|
52
|
+
headings: Array.from(root.querySelectorAll('h1,h2,h3,h4,h5,h6')).map((h) =>
|
|
53
|
+
Number(h.tagName.charAt(1)),
|
|
54
|
+
),
|
|
55
|
+
links: attrs('a[href]', 'href'),
|
|
56
|
+
images: attrs('img[src]', 'src'),
|
|
57
|
+
strong: count('strong'),
|
|
58
|
+
em: count('em'),
|
|
59
|
+
u: count('u'),
|
|
60
|
+
s: count('s'),
|
|
61
|
+
code: count('code'),
|
|
62
|
+
ul: count('ul'),
|
|
63
|
+
ol: count('ol'),
|
|
64
|
+
li: count('li'),
|
|
65
|
+
pre: count('pre'),
|
|
66
|
+
blockquote: count('blockquote'),
|
|
67
|
+
hr: count('hr'),
|
|
68
|
+
table: count('table'),
|
|
69
|
+
tr: count('tr'),
|
|
70
|
+
th: count('th'),
|
|
71
|
+
td: count('td'),
|
|
72
|
+
aside: count('aside'),
|
|
73
|
+
checkbox: count('input[type="checkbox"]'),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parity oracle (JS side).
|
|
3
|
+
*
|
|
4
|
+
* Renders shared TipTap fixtures via the static renderer and computes a
|
|
5
|
+
* SEMANTIC fingerprint (visible text + structural counts + headings/links/
|
|
6
|
+
* images). The committed `fingerprints.json` is the parity contract; the PHP
|
|
7
|
+
* test (`TiptapRendererParityTest`) asserts `TiptapHtmlRenderer` produces the
|
|
8
|
+
* SAME fingerprint. Cosmetic differences (colgroup, data-*, <mark> vs <span>,
|
|
9
|
+
* target/rel, inline-style spacing) are intentionally ignored — only content
|
|
10
|
+
* and structure must agree, since CSR previews and server PDFs can style
|
|
11
|
+
* differently.
|
|
12
|
+
*
|
|
13
|
+
* Regenerate the contract after intentional changes:
|
|
14
|
+
* UPDATE_FP=1 pnpm vitest run src/parity/parity.test.ts
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect } from 'vitest';
|
|
17
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { resolve } from 'node:path';
|
|
19
|
+
import { renderTiptapJsonToHtml } from '../lib/renderTiptapJson';
|
|
20
|
+
import { fingerprintHtml } from './fingerprint';
|
|
21
|
+
|
|
22
|
+
const FIXTURES_DIR = resolve(
|
|
23
|
+
process.cwd(),
|
|
24
|
+
'../../php/shared-editor-laravel/tests/fixtures/tiptap',
|
|
25
|
+
);
|
|
26
|
+
const fixtures = JSON.parse(
|
|
27
|
+
readFileSync(resolve(FIXTURES_DIR, 'fixtures.json'), 'utf8'),
|
|
28
|
+
) as Record<string, unknown>;
|
|
29
|
+
const fpPath = resolve(FIXTURES_DIR, 'fingerprints.json');
|
|
30
|
+
|
|
31
|
+
describe('renderer parity (JS / static-renderer)', () => {
|
|
32
|
+
if (process.env.UPDATE_FP) {
|
|
33
|
+
it('regenerates the fingerprint contract', () => {
|
|
34
|
+
const out: Record<string, unknown> = {};
|
|
35
|
+
for (const [name, doc] of Object.entries(fixtures)) {
|
|
36
|
+
out[name] = fingerprintHtml(renderTiptapJsonToHtml(doc));
|
|
37
|
+
}
|
|
38
|
+
writeFileSync(fpPath, `${JSON.stringify(out, null, 2)}\n`);
|
|
39
|
+
expect(Object.keys(out).length).toBe(Object.keys(fixtures).length);
|
|
40
|
+
});
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const expected = JSON.parse(readFileSync(fpPath, 'utf8')) as Record<string, unknown>;
|
|
45
|
+
|
|
46
|
+
for (const [name, doc] of Object.entries(fixtures)) {
|
|
47
|
+
it(`fixture "${name}" matches the contract`, () => {
|
|
48
|
+
expect(fingerprintHtml(renderTiptapJsonToHtml(doc))).toEqual(expected[name]);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
});
|