@bendyline/squisq-editor-react 0.1.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.
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Tiptap Bridge
3
+ *
4
+ * Conversion utilities between raw markdown source and Tiptap's JSON/HTML
5
+ * content format. Uses a lightweight HTML-based approach: we convert markdown
6
+ * to a simple HTML representation that Tiptap can consume, and parse
7
+ * Tiptap's HTML output back to markdown.
8
+ *
9
+ * This bridge preserves markdown semantics much better than going through
10
+ * Tiptap's native markdown extension, since we control the conversion
11
+ * using squisq's own parser.
12
+ */
13
+
14
+ // Hoisted regex patterns for inline markdown ↔ HTML conversion
15
+ const RE_BOLD_STAR = /\*\*(.+?)\*\*/g;
16
+ const RE_BOLD_UNDER = /__(.+?)__/g;
17
+ const RE_ITALIC_STAR = /\*(.+?)\*/g;
18
+ const RE_ITALIC_UNDER = /_(.+?)_/g;
19
+ const RE_STRIKETHROUGH = /~~(.+?)~~/g;
20
+ const RE_INLINE_CODE = /`(.+?)`/g;
21
+ const RE_LINK = /\[(.+?)\]\((.+?)\)/g;
22
+ const RE_IMAGE = /!\[(.+?)\]\((.+?)\)/g;
23
+ const RE_STRONG_TAG = /<strong>(.*?)<\/strong>/g;
24
+ const RE_B_TAG = /<b>(.*?)<\/b>/g;
25
+ const RE_EM_TAG = /<em>(.*?)<\/em>/g;
26
+ const RE_I_TAG = /<i>(.*?)<\/i>/g;
27
+ const RE_S_TAG = /<s>(.*?)<\/s>/g;
28
+ const RE_DEL_TAG = /<del>(.*?)<\/del>/g;
29
+ const RE_CODE_TAG = /<code>(.*?)<\/code>/g;
30
+ const RE_A_TAG = /<a[^>]+href="([^"]*)"[^>]*>(.*?)<\/a>/g;
31
+ const RE_IMG_TAG = /<img[^>]+alt="([^"]*)"[^>]+src="([^"]*)"[^>]*>/g;
32
+ const RE_STRIP_TAGS = /<[^>]+>/g;
33
+
34
+ /**
35
+ * Convert raw markdown source to Tiptap-consumable HTML content.
36
+ * Uses a simple markdown-to-HTML conversion that maps cleanly to
37
+ * Tiptap's ProseMirror schema.
38
+ */
39
+ export function markdownToTiptap(markdown: string): string {
40
+ if (!markdown.trim()) return '<p></p>';
41
+
42
+ // Simple conversion of markdown constructs to HTML that Tiptap understands.
43
+ // This is intentionally straightforward — Tiptap's parser handles the HTML.
44
+ const html = markdown;
45
+
46
+ // Process blocks line by line for accurate conversion
47
+ const lines = html.split('\n');
48
+ const outputBlocks: string[] = [];
49
+ let inCodeBlock = false;
50
+ let codeBlockLang = '';
51
+ let codeBlockLines: string[] = [];
52
+ let inList = false;
53
+ let listItems: string[] = [];
54
+ let listType: 'ul' | 'ol' | 'task' = 'ul';
55
+
56
+ const flushList = () => {
57
+ if (inList && listItems.length > 0) {
58
+ const tag = listType === 'ol' ? 'ol' : 'ul';
59
+ const attr = listType === 'task' ? ' data-type="taskList"' : '';
60
+ outputBlocks.push(`<${tag}${attr}>${listItems.join('')}</${tag}>`);
61
+ listItems = [];
62
+ inList = false;
63
+ }
64
+ };
65
+
66
+ for (let i = 0; i < lines.length; i++) {
67
+ const line = lines[i];
68
+
69
+ // Code fence handling
70
+ if (line.startsWith('```')) {
71
+ if (!inCodeBlock) {
72
+ flushList();
73
+ inCodeBlock = true;
74
+ codeBlockLang = line.slice(3).trim();
75
+ codeBlockLines = [];
76
+ continue;
77
+ } else {
78
+ const langAttr = codeBlockLang ? ` class="language-${escapeHtml(codeBlockLang)}"` : '';
79
+ outputBlocks.push(
80
+ `<pre><code${langAttr}>${escapeHtml(codeBlockLines.join('\n'))}</code></pre>`,
81
+ );
82
+ inCodeBlock = false;
83
+ codeBlockLang = '';
84
+ codeBlockLines = [];
85
+ continue;
86
+ }
87
+ }
88
+
89
+ if (inCodeBlock) {
90
+ codeBlockLines.push(line);
91
+ continue;
92
+ }
93
+
94
+ // Blank line flushes list
95
+ if (line.trim() === '') {
96
+ flushList();
97
+ continue;
98
+ }
99
+
100
+ // Headings
101
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
102
+ if (headingMatch) {
103
+ flushList();
104
+ const level = headingMatch[1].length;
105
+ let text = headingMatch[2];
106
+ let attrs = '';
107
+
108
+ // Extract {[template key=value …]} annotation
109
+ const annotMatch = text.match(/\s*\{\[([^\]]+)\]\}\s*$/);
110
+ if (annotMatch) {
111
+ text = text.slice(0, annotMatch.index!).trimEnd();
112
+ const tokens = annotMatch[1].trim().split(/\s+/);
113
+ attrs = ` data-template="${escapeHtml(tokens[0])}"`;
114
+ const params = tokens.slice(1).filter((t) => t.includes('='));
115
+ if (params.length > 0) {
116
+ attrs += ` data-template-params="${escapeHtml(params.join(' '))}"`;
117
+ }
118
+ }
119
+
120
+ outputBlocks.push(`<h${level}${attrs}>${inlineToHtml(text)}</h${level}>`);
121
+ continue;
122
+ }
123
+
124
+ // Thematic break
125
+ if (/^(---|\*\*\*|___)(\s*)$/.test(line.trim())) {
126
+ flushList();
127
+ outputBlocks.push('<hr>');
128
+ continue;
129
+ }
130
+
131
+ // Blockquote
132
+ if (line.startsWith('> ')) {
133
+ flushList();
134
+ outputBlocks.push(`<blockquote><p>${inlineToHtml(line.slice(2))}</p></blockquote>`);
135
+ continue;
136
+ }
137
+
138
+ // Task list item
139
+ const taskMatch = line.match(/^[-*+]\s+\[([xX ])\]\s+(.+)$/);
140
+ if (taskMatch) {
141
+ if (!inList || listType !== 'task') {
142
+ flushList();
143
+ inList = true;
144
+ listType = 'task';
145
+ }
146
+ const checked = taskMatch[1].toLowerCase() === 'x' ? ' data-checked="true"' : '';
147
+ listItems.push(
148
+ `<li data-type="taskItem"${checked}><label><input type="checkbox"${checked ? ' checked' : ''}>${inlineToHtml(taskMatch[2])}</label></li>`,
149
+ );
150
+ continue;
151
+ }
152
+
153
+ // Unordered list item
154
+ const ulMatch = line.match(/^[-*+]\s+(.+)$/);
155
+ if (ulMatch) {
156
+ if (!inList || listType !== 'ul') {
157
+ flushList();
158
+ inList = true;
159
+ listType = 'ul';
160
+ }
161
+ listItems.push(`<li><p>${inlineToHtml(ulMatch[1])}</p></li>`);
162
+ continue;
163
+ }
164
+
165
+ // Ordered list item
166
+ const olMatch = line.match(/^\d+\.\s+(.+)$/);
167
+ if (olMatch) {
168
+ if (!inList || listType !== 'ol') {
169
+ flushList();
170
+ inList = true;
171
+ listType = 'ol';
172
+ }
173
+ listItems.push(`<li><p>${inlineToHtml(olMatch[1])}</p></li>`);
174
+ continue;
175
+ }
176
+
177
+ // Regular paragraph
178
+ flushList();
179
+ outputBlocks.push(`<p>${inlineToHtml(line)}</p>`);
180
+ }
181
+
182
+ // Close any remaining open blocks
183
+ if (inCodeBlock) {
184
+ const langAttr = codeBlockLang ? ` class="language-${escapeHtml(codeBlockLang)}"` : '';
185
+ outputBlocks.push(
186
+ `<pre><code${langAttr}>${escapeHtml(codeBlockLines.join('\n'))}</code></pre>`,
187
+ );
188
+ }
189
+ flushList();
190
+
191
+ return outputBlocks.join('') || '<p></p>';
192
+ }
193
+
194
+ /**
195
+ * Convert Tiptap HTML output back to markdown source.
196
+ * Extracts semantic structure from HTML and produces clean markdown.
197
+ */
198
+ export function tiptapToMarkdown(html: string): string {
199
+ if (!html || html === '<p></p>') return '';
200
+
201
+ const lines: string[] = [];
202
+
203
+ // Simple regex-based HTML to markdown conversion
204
+ // This works because Tiptap produces clean, predictable HTML
205
+ let remaining = html;
206
+
207
+ while (remaining.length > 0) {
208
+ // Headings
209
+ const headingMatch = remaining.match(/^<h([1-6])([^>]*)>(.*?)<\/h\1>/s);
210
+ if (headingMatch) {
211
+ const level = parseInt(headingMatch[1], 10);
212
+ const attrs = headingMatch[2];
213
+ let text = htmlToInline(headingMatch[3]);
214
+
215
+ // Re-inject template annotation from data attributes
216
+ const tmplMatch = attrs.match(/data-template="([^"]+)"/);
217
+ if (tmplMatch) {
218
+ let annotation = tmplMatch[1];
219
+ const paramsMatch = attrs.match(/data-template-params="([^"]+)"/);
220
+ if (paramsMatch) {
221
+ annotation += ' ' + unescapeHtml(paramsMatch[1]);
222
+ }
223
+ text += ` {[${annotation}]}`;
224
+ }
225
+
226
+ lines.push('#'.repeat(level) + ' ' + text);
227
+ lines.push('');
228
+ remaining = remaining.slice(headingMatch[0].length);
229
+ continue;
230
+ }
231
+
232
+ // Code blocks
233
+ const codeMatch = remaining.match(
234
+ /^<pre><code(?:\s+class="language-([^"]*)")?>(.*?)<\/code><\/pre>/s,
235
+ );
236
+ if (codeMatch) {
237
+ const lang = codeMatch[1] || '';
238
+ const code = unescapeHtml(codeMatch[2]);
239
+ lines.push('```' + lang);
240
+ lines.push(code);
241
+ lines.push('```');
242
+ lines.push('');
243
+ remaining = remaining.slice(codeMatch[0].length);
244
+ continue;
245
+ }
246
+
247
+ // Blockquote
248
+ const bqMatch = remaining.match(/^<blockquote>(.*?)<\/blockquote>/s);
249
+ if (bqMatch) {
250
+ const inner = htmlToInline(bqMatch[1].replace(/<\/?p>/g, ''));
251
+ lines.push('> ' + inner);
252
+ lines.push('');
253
+ remaining = remaining.slice(bqMatch[0].length);
254
+ continue;
255
+ }
256
+
257
+ // Horizontal rule
258
+ if (
259
+ remaining.startsWith('<hr>') ||
260
+ remaining.startsWith('<hr/>') ||
261
+ remaining.startsWith('<hr />')
262
+ ) {
263
+ const hrMatch = remaining.match(/^<hr\s*\/?>/);
264
+ lines.push('---');
265
+ lines.push('');
266
+ remaining = remaining.slice(hrMatch![0].length);
267
+ continue;
268
+ }
269
+
270
+ // Task list
271
+ const taskListMatch = remaining.match(/^<ul[^>]*data-type="taskList"[^>]*>(.*?)<\/ul>/s);
272
+ if (taskListMatch) {
273
+ const items = taskListMatch[1].matchAll(
274
+ /<li[^>]*data-type="taskItem"[^>]*(data-checked="true")?[^>]*>.*?<\/li>/gs,
275
+ );
276
+ for (const item of items) {
277
+ const checked = item[0].includes('data-checked="true"') || item[0].includes('checked');
278
+ const textMatch = item[0].match(/<label>.*?<\/label>|<p>(.*?)<\/p>/s);
279
+ const text = textMatch ? htmlToInline(textMatch[0].replace(/<[^>]+>/g, '')) : '';
280
+ lines.push(`- [${checked ? 'x' : ' '}] ${text}`);
281
+ }
282
+ lines.push('');
283
+ remaining = remaining.slice(taskListMatch[0].length);
284
+ continue;
285
+ }
286
+
287
+ // Unordered list
288
+ const ulMatch = remaining.match(/^<ul>(.*?)<\/ul>/s);
289
+ if (ulMatch) {
290
+ const items = ulMatch[1].matchAll(/<li>(.*?)<\/li>/gs);
291
+ for (const item of items) {
292
+ lines.push('- ' + htmlToInline(item[1].replace(/<\/?p>/g, '')));
293
+ }
294
+ lines.push('');
295
+ remaining = remaining.slice(ulMatch[0].length);
296
+ continue;
297
+ }
298
+
299
+ // Ordered list
300
+ const olMatch = remaining.match(/^<ol[^>]*>(.*?)<\/ol>/s);
301
+ if (olMatch) {
302
+ const items = [...olMatch[1].matchAll(/<li>(.*?)<\/li>/gs)];
303
+ items.forEach((item, idx) => {
304
+ lines.push(`${idx + 1}. ` + htmlToInline(item[1].replace(/<\/?p>/g, '')));
305
+ });
306
+ lines.push('');
307
+ remaining = remaining.slice(olMatch[0].length);
308
+ continue;
309
+ }
310
+
311
+ // Paragraph
312
+ const pMatch = remaining.match(/^<p>(.*?)<\/p>/s);
313
+ if (pMatch) {
314
+ const text = htmlToInline(pMatch[1]);
315
+ if (text.trim()) {
316
+ lines.push(text);
317
+ lines.push('');
318
+ }
319
+ remaining = remaining.slice(pMatch[0].length);
320
+ continue;
321
+ }
322
+
323
+ // Skip unknown tags or whitespace
324
+ const skipMatch = remaining.match(/^(<[^>]+>|\s+)/);
325
+ if (skipMatch) {
326
+ remaining = remaining.slice(skipMatch[0].length);
327
+ continue;
328
+ }
329
+
330
+ // Plain text (shouldn't happen in well-formed Tiptap output)
331
+ const textMatch = remaining.match(/^([^<]+)/);
332
+ if (textMatch) {
333
+ lines.push(unescapeHtml(textMatch[1]));
334
+ remaining = remaining.slice(textMatch[0].length);
335
+ continue;
336
+ }
337
+
338
+ // Safety: skip one character to avoid infinite loop
339
+ remaining = remaining.slice(1);
340
+ }
341
+
342
+ // Clean up trailing blank lines
343
+ return (
344
+ lines
345
+ .join('\n')
346
+ .replace(/\n{3,}/g, '\n\n')
347
+ .trim() + '\n'
348
+ );
349
+ }
350
+
351
+ // ─── Helpers ─────────────────────────────────────────────
352
+
353
+ function escapeHtml(text: string): string {
354
+ return text
355
+ .replace(/&/g, '&amp;')
356
+ .replace(/</g, '&lt;')
357
+ .replace(/>/g, '&gt;')
358
+ .replace(/"/g, '&quot;');
359
+ }
360
+
361
+ function unescapeHtml(text: string): string {
362
+ return text
363
+ .replace(/&lt;/g, '<')
364
+ .replace(/&gt;/g, '>')
365
+ .replace(/&quot;/g, '"')
366
+ .replace(/&amp;/g, '&');
367
+ }
368
+
369
+ /** Convert inline markdown to HTML for Tiptap consumption */
370
+ function inlineToHtml(text: string): string {
371
+ let result = escapeHtml(text);
372
+
373
+ // Bold: **text** or __text__
374
+ result = result.replace(RE_BOLD_STAR, '<strong>$1</strong>');
375
+ result = result.replace(RE_BOLD_UNDER, '<strong>$1</strong>');
376
+
377
+ // Italic: *text* or _text_
378
+ result = result.replace(RE_ITALIC_STAR, '<em>$1</em>');
379
+ result = result.replace(RE_ITALIC_UNDER, '<em>$1</em>');
380
+
381
+ // Strikethrough: ~~text~~
382
+ result = result.replace(RE_STRIKETHROUGH, '<s>$1</s>');
383
+
384
+ // Inline code: `text`
385
+ result = result.replace(RE_INLINE_CODE, '<code>$1</code>');
386
+
387
+ // Links: [text](url)
388
+ result = result.replace(RE_LINK, '<a href="$2">$1</a>');
389
+
390
+ // Images: ![alt](src)
391
+ result = result.replace(RE_IMAGE, '<img alt="$1" src="$2">');
392
+
393
+ return result;
394
+ }
395
+
396
+ /** Convert inline HTML back to markdown */
397
+ function htmlToInline(html: string): string {
398
+ let result = html;
399
+
400
+ // Strong
401
+ result = result.replace(RE_STRONG_TAG, '**$1**');
402
+ result = result.replace(RE_B_TAG, '**$1**');
403
+
404
+ // Em
405
+ result = result.replace(RE_EM_TAG, '*$1*');
406
+ result = result.replace(RE_I_TAG, '*$1*');
407
+
408
+ // Strikethrough
409
+ result = result.replace(RE_S_TAG, '~~$1~~');
410
+ result = result.replace(RE_DEL_TAG, '~~$1~~');
411
+
412
+ // Code
413
+ result = result.replace(RE_CODE_TAG, '`$1`');
414
+
415
+ // Links
416
+ result = result.replace(RE_A_TAG, '[$2]($1)');
417
+
418
+ // Images
419
+ result = result.replace(RE_IMG_TAG, '![$1]($2)');
420
+
421
+ // Strip remaining tags
422
+ result = result.replace(RE_STRIP_TAGS, '');
423
+
424
+ return unescapeHtml(result);
425
+ }