@ceedcv-maya/shared-editor-react 0.9.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.9.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
+ }
@@ -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';
@@ -141,6 +142,10 @@ export function MayaEditor({
141
142
 
142
143
  const extensions = useMemo(() => buildMayaEditorExtensions(mode), [mode]);
143
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
+
144
149
  const editor = useEditor({
145
150
  extensions,
146
151
  content: initialContent ?? '',
@@ -153,9 +158,28 @@ export function MayaEditor({
153
158
  // Reshape pasted HTML so complex tables (caption/tfoot/colgroup)
154
159
  // survive TipTap's parser. See `normalizeTableHtml` for details.
155
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
+ },
156
178
  },
157
179
  });
158
180
 
181
+ editorRef.current = editor;
182
+
159
183
  useEditorContent(editor, onChange, { output: effectiveOutput });
160
184
 
161
185
  const readPayloadFromEditor = useCallback((): string | TiptapDoc | undefined => {
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 {
@@ -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
+ }
@@ -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 {
@@ -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
+ });