@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 +4 -1
- package/src/components/EditorContentJson.tsx +29 -0
- package/src/components/MayaEditor.tsx +24 -0
- package/src/index.ts +2 -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.ts +1 -1
- 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
|
+
}
|
|
@@ -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
|
-
|
|
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,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
|
+
});
|