@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ceedcv-maya/shared-editor-react",
3
- "version": "0.8.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 al perder el foco del editor, antes de HTML/Markdown o al destruir
35
- * la instancia. El padre suele enlazarlo a `forceSave` del autoguardado.
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 syncContentToParent = useCallback(() => {
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
- const payload =
160
- effectiveOutput === 'json'
161
- ? normalizeTiptapDocPayload(rawPayload)
162
- : rawPayload;
163
- handler(payload);
191
+ return effectiveOutput === 'json'
192
+ ? normalizeTiptapDocPayload(rawPayload)
193
+ : rawPayload;
164
194
  }, [editor, effectiveOutput]);
165
195
 
166
- const requestFlush = useCallback(() => {
167
- syncContentToParent();
168
- onFlushRef.current?.();
169
- }, [syncContentToParent]);
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 = () => requestFlush();
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
- requestFlush();
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
- out = out.replace(/(?<!_)_([^_\n]+)_(?!_)/g, '<em>$1</em>');
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 inlineTextLength(n.content ?? []) === 0;
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
- * Returns a new array; does not mutate the input.
198
+ * Preserves the full node tree (text, marks, heading levels) for persistence.
86
199
  */
87
- export function normalizeTiptapContentForCompare(value: unknown): unknown[] {
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: normalizeTiptapContentForCompare(payload.content) as TiptapDoc['content'],
264
+ content: normalizeTiptapContentForPersistence(payload.content) as TiptapDoc['content'],
140
265
  };
141
266
  }
@@ -0,0 +1,3 @@
1
+ // mammoth ships no types for its browser entrypoint; it's imported dynamically
2
+ // and immediately cast to a narrow shape in docxToHtml.ts.
3
+ declare module 'mammoth/mammoth.browser.js';
@@ -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
+ });