@ceedcv-maya/shared-editor-react 0.9.0 → 0.11.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.
@@ -2,10 +2,13 @@
2
2
  * EditorToolbar button groups extracted into focused subcomponents.
3
3
  * Each group handles a logically distinct set of formatting or block operations.
4
4
  */
5
+ import { Fragment } from 'react';
5
6
  import type { Editor } from '@tiptap/react';
6
7
  import type { ToolbarLabels } from './EditorToolbar';
7
8
  import { ColorPicker } from './ColorPicker';
8
9
  import { Btn } from './EditorToolbarButton';
10
+ import { EditorIcon } from './EditorIcons';
11
+ import { tableMenuActions } from '../lib/tableMenuActions';
9
12
 
10
13
  export interface ToolbarGroupProps {
11
14
  editor: Editor;
@@ -35,31 +38,31 @@ export function FormattingButtons({ editor, labels: L }: ToolbarGroupProps) {
35
38
  onClick={() => editor.chain().focus().toggleBold().run()}
36
39
  title={L.bold}
37
40
  >
38
- <strong>B</strong>
41
+ <EditorIcon name="bold" />
39
42
  </Btn>
40
43
  <Btn
41
44
  active={editor.isActive('italic')}
42
45
  onClick={() => editor.chain().focus().toggleItalic().run()}
43
46
  title={L.italic}
44
47
  >
45
- <em>I</em>
48
+ <EditorIcon name="italic" />
46
49
  </Btn>
47
50
  <Btn
48
51
  active={editor.isActive('underline')}
49
52
  onClick={() => editor.chain().focus().toggleUnderline().run()}
50
53
  title={L.underline}
51
54
  >
52
- <u>U</u>
55
+ <EditorIcon name="underline" />
53
56
  </Btn>
54
57
  <Btn
55
58
  active={editor.isActive('code')}
56
59
  onClick={() => editor.chain().focus().toggleCode().run()}
57
60
  title={L.code}
58
61
  >
59
- {'</>'}
62
+ <EditorIcon name="code" />
60
63
  </Btn>
61
64
  <Btn active={editor.isActive('link')} onClick={setLink} title={L.link}>
62
- 🔗
65
+ <EditorIcon name="link" />
63
66
  </Btn>
64
67
  </>
65
68
  );
@@ -76,13 +79,13 @@ export function AdvancedFormattingButtons({ editor, labels: L }: ToolbarGroupPro
76
79
  onClick={() => editor.chain().focus().toggleStrike().run()}
77
80
  title={L.strike}
78
81
  >
79
- <s>S</s>
82
+ <EditorIcon name="strike" />
80
83
  </Btn>
81
84
 
82
85
  <ColorPicker
83
86
  title={L.textColor}
84
87
  value={(editor.getAttributes('textStyle').color as string | undefined) ?? null}
85
- glyph={<span style={{ fontWeight: 700 }}>A</span>}
88
+ glyph={<EditorIcon name="textColor" />}
86
89
  clearLabel={L.colorDefault}
87
90
  onSelect={(c) => {
88
91
  if (c === null) editor.chain().focus().unsetColor().run();
@@ -92,7 +95,7 @@ export function AdvancedFormattingButtons({ editor, labels: L }: ToolbarGroupPro
92
95
  <ColorPicker
93
96
  title={L.backgroundColor}
94
97
  value={(editor.getAttributes('highlight').color as string | undefined) ?? null}
95
- glyph={<span style={{ fontWeight: 700 }}>▮</span>}
98
+ glyph={<EditorIcon name="highlight" />}
96
99
  clearLabel={L.colorDefault}
97
100
  onSelect={(c) => {
98
101
  if (c === null) editor.chain().focus().unsetHighlight().run();
@@ -114,28 +117,28 @@ export function AlignmentButtons({ editor, labels: L }: ToolbarGroupProps) {
114
117
  onClick={() => editor.chain().focus().setTextAlign('left').run()}
115
118
  title={L.alignLeft}
116
119
  >
117
-
120
+ <EditorIcon name="alignLeft" />
118
121
  </Btn>
119
122
  <Btn
120
123
  active={editor.isActive({ textAlign: 'center' })}
121
124
  onClick={() => editor.chain().focus().setTextAlign('center').run()}
122
125
  title={L.alignCenter}
123
126
  >
124
-
127
+ <EditorIcon name="alignCenter" />
125
128
  </Btn>
126
129
  <Btn
127
130
  active={editor.isActive({ textAlign: 'right' })}
128
131
  onClick={() => editor.chain().focus().setTextAlign('right').run()}
129
132
  title={L.alignRight}
130
133
  >
131
-
134
+ <EditorIcon name="alignRight" />
132
135
  </Btn>
133
136
  <Btn
134
137
  active={editor.isActive({ textAlign: 'justify' })}
135
138
  onClick={() => editor.chain().focus().setTextAlign('justify').run()}
136
139
  title={L.alignJustify}
137
140
  >
138
-
141
+ <EditorIcon name="alignJustify" />
139
142
  </Btn>
140
143
  </>
141
144
  );
@@ -159,7 +162,7 @@ export function IndentButtons({ editor, labels: L }: ToolbarGroupProps) {
159
162
  }}
160
163
  title={L.indent}
161
164
  >
162
-
165
+ <EditorIcon name="indent" />
163
166
  </Btn>
164
167
  <Btn
165
168
  onClick={() => {
@@ -173,7 +176,7 @@ export function IndentButtons({ editor, labels: L }: ToolbarGroupProps) {
173
176
  }}
174
177
  title={L.outdent}
175
178
  >
176
-
179
+ <EditorIcon name="outdent" />
177
180
  </Btn>
178
181
  </>
179
182
  );
@@ -221,41 +224,41 @@ export function ListAndBlockButtons({ editor, labels: L }: ToolbarGroupProps) {
221
224
  onClick={() => editor.chain().focus().toggleBulletList().run()}
222
225
  title={L.bulletList}
223
226
  >
224
-
227
+ <EditorIcon name="bulletList" />
225
228
  </Btn>
226
229
  <Btn
227
230
  active={editor.isActive('orderedList')}
228
231
  onClick={() => editor.chain().focus().toggleOrderedList().run()}
229
232
  title={L.orderedList}
230
233
  >
231
- 1.
234
+ <EditorIcon name="orderedList" />
232
235
  </Btn>
233
236
  <Btn
234
237
  active={editor.isActive('taskList')}
235
238
  onClick={() => editor.chain().focus().toggleTaskList().run()}
236
239
  title={L.taskList}
237
240
  >
238
-
241
+ <EditorIcon name="taskList" />
239
242
  </Btn>
240
243
  <Btn
241
244
  active={editor.isActive('blockquote')}
242
245
  onClick={() => editor.chain().focus().toggleBlockquote().run()}
243
246
  title={L.blockquote}
244
247
  >
245
-
248
+ <EditorIcon name="blockquote" />
246
249
  </Btn>
247
250
  <Btn
248
251
  active={editor.isActive('codeBlock')}
249
252
  onClick={() => editor.chain().focus().toggleCodeBlock().run()}
250
253
  title={L.codeBlock}
251
254
  >
252
- {'{}'}
255
+ <EditorIcon name="codeBlock" />
253
256
  </Btn>
254
257
  <Btn
255
258
  onClick={() => editor.chain().focus().setHorizontalRule().run()}
256
259
  title={L.horizontalRule}
257
260
  >
258
-
261
+ <EditorIcon name="horizontalRule" />
259
262
  </Btn>
260
263
  </>
261
264
  );
@@ -287,20 +290,41 @@ export function TableAndMediaButtons({
287
290
  }
288
291
  title={L.table}
289
292
  >
290
-
293
+ <EditorIcon name="table" />
291
294
  </Btn>
292
295
  <Btn onClick={setIframe} title={L.iframe}>
293
- 🖽
296
+ <EditorIcon name="iframe" />
294
297
  </Btn>
295
298
  {onImage && (
296
299
  <Btn onClick={onImage} title={L.uploadImage}>
297
- 🖼
300
+ <EditorIcon name="image" />
298
301
  </Btn>
299
302
  )}
300
303
  </>
301
304
  );
302
305
  }
303
306
 
307
+ /**
308
+ * Contextual table-editing buttons: add/delete rows & columns, toggle header,
309
+ * delete table. Rendered in the main toolbar only while the selection is inside
310
+ * a table (the caller gates on `editor.isActive('table')`), so it appears and
311
+ * hides in place instead of floating over the content.
312
+ */
313
+ export function TableButtons({ editor, labels: L }: ToolbarGroupProps) {
314
+ return (
315
+ <>
316
+ {tableMenuActions(editor, L).map((a) => (
317
+ <Fragment key={a.key}>
318
+ {a.separatorBefore && <span className="maya-editor-toolbar__sep" aria-hidden />}
319
+ <Btn onClick={a.run} title={a.title} disabled={a.disabled}>
320
+ <EditorIcon name={a.icon} />
321
+ </Btn>
322
+ </Fragment>
323
+ ))}
324
+ </>
325
+ );
326
+ }
327
+
304
328
  /**
305
329
  * Import/export and document buttons: Word import/export, comments, find.
306
330
  */
@@ -323,12 +347,12 @@ export function DocumentButtons({
323
347
  <>
324
348
  {onImportDocx && (
325
349
  <Btn onClick={onImportDocx} title={L.importDocx}>
326
- <span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 11 }}>↥W</span>
350
+ <EditorIcon name="importDocx" />
327
351
  </Btn>
328
352
  )}
329
353
  {onExportDocx && (
330
354
  <Btn onClick={onExportDocx} title={L.exportDocx}>
331
- <span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 11 }}>↧W</span>
355
+ <EditorIcon name="exportDocx" />
332
356
  </Btn>
333
357
  )}
334
358
  {onAddComment && (
@@ -337,12 +361,12 @@ export function DocumentButtons({
337
361
  disabled={editor.state.selection.empty}
338
362
  title={L.addComment}
339
363
  >
340
- 💬
364
+ <EditorIcon name="comment" />
341
365
  </Btn>
342
366
  )}
343
367
  {onToggleFind && (
344
368
  <Btn onClick={onToggleFind} title={L.find}>
345
- 🔍
369
+ <EditorIcon name="find" />
346
370
  </Btn>
347
371
  )}
348
372
  </>
@@ -385,7 +409,7 @@ export function ViewModeButtons({
385
409
  title={L.insertHtml}
386
410
  active={viewMode === 'html'}
387
411
  >
388
- <span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 11 }}>{'<>'}</span>
412
+ <EditorIcon name="htmlSource" />
389
413
  </Btn>
390
414
  )}
391
415
  <span className="maya-editor-toolbar__sep" aria-hidden />
@@ -394,7 +418,7 @@ export function ViewModeButtons({
394
418
  onClick={onToggleFullscreen}
395
419
  title={isFullscreen ? L.exitFullscreen : L.fullscreen}
396
420
  >
397
- {isFullscreen ? '🗗' : '🗖'}
421
+ <EditorIcon name={isFullscreen ? 'exitFullscreen' : 'fullscreen'} />
398
422
  </Btn>
399
423
  )}
400
424
  </>
@@ -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 => {
@@ -302,6 +326,18 @@ export function MayaEditor({
302
326
  onFullscreenChange(isFullscreen);
303
327
  }, [isFullscreen, onFullscreenChange]);
304
328
 
329
+ // Mirror fullscreen to a global `editor-fullscreen` class on <html> so the
330
+ // host AppLayout can hide its fixed sidebar and drop the content margin while
331
+ // the editor covers the viewport. Self-contained: works even when the host
332
+ // doesn't handle `onFullscreenChange` (e.g. the inline editors in the
333
+ // continuous document view) — otherwise the fixed editor sits *under* the
334
+ // still-visible sidebar and the left half is hidden.
335
+ useEffect(() => {
336
+ const root = document.documentElement;
337
+ root.classList.toggle('editor-fullscreen', isFullscreen);
338
+ return () => root.classList.remove('editor-fullscreen');
339
+ }, [isFullscreen]);
340
+
305
341
  if (!editor) return null;
306
342
 
307
343
  const editorReady = viewReady && isEditorReady(editor);
package/src/index.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  export { MayaEditor } from './components/MayaEditor';
2
2
  export type { MayaEditorProps } from './components/MayaEditor';
3
3
  export { EditorContentHtml } from './components/EditorContentHtml';
4
- export { EditorToolbar } from './components/EditorToolbar';
4
+ export { EditorContentJson } from './components/EditorContentJson';
5
+ export { EditorToolbar, DEFAULT_LABELS } from './components/EditorToolbar';
5
6
  export type { ToolbarLabels } from './components/EditorToolbar';
7
+ export { buildToolbarLabels } from './lib/buildToolbarLabels';
8
+ export type { TranslateFn } from './lib/buildToolbarLabels';
6
9
 
7
10
  export { IframeBlock } from './extensions/IframeBlock';
8
11
  export { AlertBlock } from './extensions/AlertBlock';
@@ -23,6 +26,7 @@ export { normalizeTableHtml } from './lib/normalizeTableHtml';
23
26
  export { splitHtmlIntoBlocks } from './lib/splitHtmlIntoBlocks';
24
27
  export type { BlockChunk, BlockChunkType } from './lib/splitHtmlIntoBlocks';
25
28
  export { htmlToTiptapDoc } from './lib/htmlToTiptapDoc';
29
+ export { renderTiptapJsonToHtml } from './lib/renderTiptapJson';
26
30
  export { buildMayaEditorExtensions } from './lib/editorExtensions';
27
31
  export { isEditorReady } from './lib/isEditorReady';
28
32
  export {
@@ -50,6 +54,3 @@ export type {
50
54
  TiptapDoc,
51
55
  AnchoredComment,
52
56
  } from './types';
53
-
54
- export { default as esTranslations } from './i18n/es.json';
55
- export { default as enTranslations } from './i18n/en.json';
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildToolbarLabels } from './buildToolbarLabels';
3
+ import { DEFAULT_LABELS } from '../components/EditorToolbar';
4
+
5
+ describe('buildToolbarLabels', () => {
6
+ it('maps every label key through t("editor.<key>")', () => {
7
+ const dict: Record<string, string> = {
8
+ 'editor.bold': 'Negrita',
9
+ 'editor.italic': 'Cursiva',
10
+ 'editor.groupFont': 'Fuente',
11
+ 'editor.tableAddColumnAfter': 'Insertar columna a la derecha',
12
+ };
13
+ const t = (k: string) => dict[k] ?? k;
14
+
15
+ const labels = buildToolbarLabels(t);
16
+ expect(labels.bold).toBe('Negrita');
17
+ expect(labels.italic).toBe('Cursiva');
18
+ expect(labels.groupFont).toBe('Fuente');
19
+ expect(labels.tableAddColumnAfter).toBe('Insertar columna a la derecha');
20
+ });
21
+
22
+ it('falls back to the English default when a key is missing (t returns the key)', () => {
23
+ const t = (k: string) => k; // i18next miss behaviour: returns the key
24
+ const labels = buildToolbarLabels(t);
25
+ expect(labels.bold).toBe(DEFAULT_LABELS.bold);
26
+ expect(labels.groupParagraph).toBe(DEFAULT_LABELS.groupParagraph);
27
+ // never leaks a raw "editor.*" key
28
+ expect(labels.bold).not.toContain('editor.');
29
+ });
30
+
31
+ it('honours a custom prefix', () => {
32
+ const t = (k: string) => (k === 'tt.bold' ? 'B!' : k);
33
+ const labels = buildToolbarLabels(t, 'tt');
34
+ expect(labels.bold).toBe('B!');
35
+ });
36
+
37
+ it('returns a fully-populated object (all default keys present)', () => {
38
+ const labels = buildToolbarLabels((k) => k);
39
+ for (const key of Object.keys(DEFAULT_LABELS)) {
40
+ expect(labels[key as keyof typeof labels]).toBeTruthy();
41
+ }
42
+ });
43
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Build a fully-populated {@link ToolbarLabels} from a translation function.
3
+ *
4
+ * The editor itself is i18n-agnostic (it just takes a `toolbarLabels` prop), so
5
+ * each consumer wires its own i18n. This helper keeps that wiring to one line:
6
+ * pass the app's `t` (bound to the namespace that holds the shared `editor`
7
+ * canon — usually `common`) and it maps every label key to `t('editor.<key>')`.
8
+ *
9
+ * const { t } = useTranslation('common');
10
+ * <MayaEditor toolbarLabels={buildToolbarLabels(t)} … />
11
+ *
12
+ * Any key missing from the active locale falls back to the English default
13
+ * (i18next returns the key unchanged for a miss, which we detect and replace),
14
+ * so a partially-translated locale never shows raw `editor.*` keys.
15
+ */
16
+ import { DEFAULT_LABELS } from '../components/EditorToolbar';
17
+ import type { ToolbarLabels } from '../components/EditorToolbar';
18
+
19
+ /** Minimal shape of an i18next `t` — only the (key) → string call is used. */
20
+ export type TranslateFn = (key: string) => string;
21
+
22
+ export function buildToolbarLabels(
23
+ t: TranslateFn,
24
+ prefix = 'editor',
25
+ ): ToolbarLabels {
26
+ const out: ToolbarLabels = { ...DEFAULT_LABELS };
27
+
28
+ (Object.keys(DEFAULT_LABELS) as (keyof ToolbarLabels)[]).forEach((key) => {
29
+ const fullKey = `${prefix}.${key}`;
30
+ const value = t(fullKey);
31
+ // i18next returns the key itself on a miss → keep the English default.
32
+ if (value && value !== fullKey) {
33
+ out[key] = value;
34
+ }
35
+ });
36
+
37
+ return out;
38
+ }
@@ -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.