@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.
- package/dist/index.d.ts +269 -0
- package/dist/index.js +3825 -0
- package/dist/index.js.map +1 -0
- package/package.json +72 -0
- package/src/EditorContext.tsx +251 -0
- package/src/EditorShell.tsx +139 -0
- package/src/PreviewPanel.tsx +562 -0
- package/src/RawEditor.tsx +151 -0
- package/src/StatusBar.tsx +48 -0
- package/src/TemplateAnnotation.ts +71 -0
- package/src/Toolbar.tsx +465 -0
- package/src/ViewSwitcher.tsx +46 -0
- package/src/WysiwygEditor.tsx +134 -0
- package/src/index.ts +58 -0
- package/src/styles/editor.css +594 -0
- package/src/tiptapBridge.ts +425 -0
|
@@ -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, '&')
|
|
356
|
+
.replace(/</g, '<')
|
|
357
|
+
.replace(/>/g, '>')
|
|
358
|
+
.replace(/"/g, '"');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function unescapeHtml(text: string): string {
|
|
362
|
+
return text
|
|
363
|
+
.replace(/</g, '<')
|
|
364
|
+
.replace(/>/g, '>')
|
|
365
|
+
.replace(/"/g, '"')
|
|
366
|
+
.replace(/&/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: 
|
|
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, '');
|
|
420
|
+
|
|
421
|
+
// Strip remaining tags
|
|
422
|
+
result = result.replace(RE_STRIP_TAGS, '');
|
|
423
|
+
|
|
424
|
+
return unescapeHtml(result);
|
|
425
|
+
}
|