suneditor 3.0.0-rc.4 → 3.0.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/README.md +4 -3
- package/dist/suneditor-contents.min.css +1 -1
- package/dist/suneditor.min.css +1 -1
- package/dist/suneditor.min.js +1 -1
- package/package.json +10 -6
- package/src/assets/design/color.css +14 -2
- package/src/assets/design/typography.css +5 -0
- package/src/assets/icons/defaultIcons.js +22 -4
- package/src/assets/suneditor-contents.css +1 -1
- package/src/assets/suneditor.css +312 -18
- package/src/core/config/eventManager.js +6 -9
- package/src/core/editor.js +1 -1
- package/src/core/event/actions/index.js +5 -0
- package/src/core/event/effects/keydown.registry.js +25 -0
- package/src/core/event/eventOrchestrator.js +69 -2
- package/src/core/event/handlers/handler_ww_mouse.js +1 -0
- package/src/core/event/rules/keydown.rule.backspace.js +9 -1
- package/src/core/kernel/coreKernel.js +4 -0
- package/src/core/kernel/store.js +2 -0
- package/src/core/logic/dom/char.js +11 -0
- package/src/core/logic/dom/format.js +22 -0
- package/src/core/logic/dom/html.js +126 -11
- package/src/core/logic/dom/nodeTransform.js +13 -0
- package/src/core/logic/dom/offset.js +100 -37
- package/src/core/logic/dom/selection.js +54 -22
- package/src/core/logic/panel/finder.js +982 -0
- package/src/core/logic/panel/menu.js +8 -6
- package/src/core/logic/panel/toolbar.js +112 -19
- package/src/core/logic/panel/viewer.js +214 -43
- package/src/core/logic/shell/_commandExecutor.js +7 -1
- package/src/core/logic/shell/commandDispatcher.js +1 -1
- package/src/core/logic/shell/component.js +5 -7
- package/src/core/logic/shell/history.js +24 -0
- package/src/core/logic/shell/shortcuts.js +3 -3
- package/src/core/logic/shell/ui.js +25 -26
- package/src/core/schema/frameContext.js +15 -1
- package/src/core/schema/options.js +180 -39
- package/src/core/section/constructor.js +61 -20
- package/src/core/section/documentType.js +2 -2
- package/src/events.js +12 -0
- package/src/helper/clipboard.js +1 -1
- package/src/helper/converter.js +15 -0
- package/src/helper/dom/domQuery.js +12 -0
- package/src/helper/dom/domUtils.js +26 -14
- package/src/helper/index.js +3 -0
- package/src/helper/markdown.js +876 -0
- package/src/interfaces/plugins.js +7 -5
- package/src/langs/ckb.js +9 -0
- package/src/langs/cs.js +9 -0
- package/src/langs/da.js +9 -0
- package/src/langs/de.js +9 -0
- package/src/langs/en.js +9 -0
- package/src/langs/es.js +9 -0
- package/src/langs/fa.js +9 -0
- package/src/langs/fr.js +9 -0
- package/src/langs/he.js +9 -0
- package/src/langs/hu.js +9 -0
- package/src/langs/it.js +9 -0
- package/src/langs/ja.js +9 -0
- package/src/langs/km.js +9 -0
- package/src/langs/ko.js +9 -0
- package/src/langs/lv.js +9 -0
- package/src/langs/nl.js +9 -0
- package/src/langs/pl.js +9 -0
- package/src/langs/pt_br.js +9 -0
- package/src/langs/ro.js +9 -0
- package/src/langs/ru.js +9 -0
- package/src/langs/se.js +9 -0
- package/src/langs/tr.js +9 -0
- package/src/langs/uk.js +9 -0
- package/src/langs/ur.js +9 -0
- package/src/langs/zh_cn.js +9 -0
- package/src/modules/contract/Browser.js +31 -1
- package/src/modules/contract/ColorPicker.js +6 -0
- package/src/modules/contract/Controller.js +77 -39
- package/src/modules/contract/Figure.js +57 -0
- package/src/modules/contract/Modal.js +6 -0
- package/src/modules/manager/ApiManager.js +53 -4
- package/src/modules/manager/FileManager.js +18 -1
- package/src/modules/ui/ModalAnchorEditor.js +35 -2
- package/src/modules/ui/SelectMenu.js +44 -12
- package/src/plugins/browser/fileBrowser.js +5 -2
- package/src/plugins/command/codeBlock.js +324 -0
- package/src/plugins/command/exportPDF.js +15 -3
- package/src/plugins/command/fileUpload.js +4 -1
- package/src/plugins/dropdown/backgroundColor.js +5 -1
- package/src/plugins/dropdown/blockStyle.js +8 -2
- package/src/plugins/dropdown/fontColor.js +5 -1
- package/src/plugins/dropdown/hr.js +6 -0
- package/src/plugins/dropdown/layout.js +4 -1
- package/src/plugins/dropdown/lineHeight.js +3 -0
- package/src/plugins/dropdown/paragraphStyle.js +5 -5
- package/src/plugins/dropdown/table/index.js +4 -1
- package/src/plugins/dropdown/table/render/table.html.js +1 -1
- package/src/plugins/dropdown/table/services/table.grid.js +16 -8
- package/src/plugins/dropdown/table/services/table.style.js +5 -9
- package/src/plugins/dropdown/template.js +3 -0
- package/src/plugins/dropdown/textStyle.js +5 -1
- package/src/plugins/field/mention.js +5 -1
- package/src/plugins/index.js +3 -0
- package/src/plugins/input/fontSize.js +10 -3
- package/src/plugins/modal/audio.js +7 -3
- package/src/plugins/modal/embed.js +23 -20
- package/src/plugins/modal/image/index.js +5 -1
- package/src/plugins/modal/math.js +7 -2
- package/src/plugins/modal/video/index.js +21 -4
- package/src/themes/cobalt.css +13 -4
- package/src/themes/cream.css +11 -2
- package/src/themes/dark.css +13 -4
- package/src/themes/midnight.css +13 -4
- package/src/typedef.js +4 -4
- package/types/assets/icons/defaultIcons.d.ts +12 -1
- package/types/assets/suneditor.css.d.ts +1 -1
- package/types/core/config/eventManager.d.ts +6 -8
- package/types/core/event/actions/index.d.ts +1 -0
- package/types/core/event/effects/keydown.registry.d.ts +2 -0
- package/types/core/event/eventOrchestrator.d.ts +2 -1
- package/types/core/kernel/coreKernel.d.ts +5 -0
- package/types/core/kernel/store.d.ts +5 -0
- package/types/core/logic/dom/char.d.ts +11 -0
- package/types/core/logic/dom/format.d.ts +22 -0
- package/types/core/logic/dom/html.d.ts +16 -0
- package/types/core/logic/dom/nodeTransform.d.ts +13 -0
- package/types/core/logic/dom/offset.d.ts +23 -2
- package/types/core/logic/dom/selection.d.ts +9 -3
- package/types/core/logic/panel/finder.d.ts +83 -0
- package/types/core/logic/panel/toolbar.d.ts +14 -1
- package/types/core/logic/panel/viewer.d.ts +22 -2
- package/types/core/logic/shell/shortcuts.d.ts +1 -1
- package/types/core/schema/frameContext.d.ts +22 -0
- package/types/core/schema/options.d.ts +362 -79
- package/types/events.d.ts +11 -0
- package/types/helper/converter.d.ts +15 -0
- package/types/helper/dom/domQuery.d.ts +12 -0
- package/types/helper/dom/domUtils.d.ts +23 -2
- package/types/helper/index.d.ts +5 -0
- package/types/helper/markdown.d.ts +27 -0
- package/types/interfaces/plugins.d.ts +7 -5
- package/types/langs/_Lang.d.ts +9 -0
- package/types/modules/contract/Browser.d.ts +36 -2
- package/types/modules/contract/ColorPicker.d.ts +6 -0
- package/types/modules/contract/Controller.d.ts +35 -1
- package/types/modules/contract/Figure.d.ts +57 -0
- package/types/modules/contract/Modal.d.ts +6 -0
- package/types/modules/manager/ApiManager.d.ts +26 -0
- package/types/modules/manager/FileManager.d.ts +17 -0
- package/types/modules/ui/ModalAnchorEditor.d.ts +41 -4
- package/types/modules/ui/SelectMenu.d.ts +40 -2
- package/types/plugins/browser/fileBrowser.d.ts +10 -4
- package/types/plugins/command/codeBlock.d.ts +53 -0
- package/types/plugins/command/fileUpload.d.ts +8 -2
- package/types/plugins/dropdown/backgroundColor.d.ts +10 -2
- package/types/plugins/dropdown/blockStyle.d.ts +14 -2
- package/types/plugins/dropdown/fontColor.d.ts +10 -2
- package/types/plugins/dropdown/hr.d.ts +12 -0
- package/types/plugins/dropdown/layout.d.ts +8 -2
- package/types/plugins/dropdown/lineHeight.d.ts +6 -0
- package/types/plugins/dropdown/paragraphStyle.d.ts +14 -3
- package/types/plugins/dropdown/table/index.d.ts +9 -3
- package/types/plugins/dropdown/template.d.ts +6 -0
- package/types/plugins/dropdown/textStyle.d.ts +10 -2
- package/types/plugins/field/mention.d.ts +10 -2
- package/types/plugins/index.d.ts +3 -0
- package/types/plugins/input/fontSize.d.ts +18 -4
- package/types/plugins/modal/audio.d.ts +14 -6
- package/types/plugins/modal/embed.d.ts +44 -38
- package/types/plugins/modal/image/index.d.ts +9 -1
- package/types/plugins/modal/link.d.ts +6 -2
- package/types/plugins/modal/math.d.ts +23 -5
- package/types/plugins/modal/video/index.d.ts +49 -9
- package/types/typedef.d.ts +5 -2
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Markdown converter module
|
|
3
|
+
* - Supports GitHub Flavored Markdown (GFM) syntax
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const VOID_ELEMENTS = /^(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/i;
|
|
7
|
+
const ZERO_WIDTH_RE = /^[\u200B\uFEFF]+$/;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @description Check if a string is empty or contains only zero-width characters
|
|
11
|
+
* @param {string} str
|
|
12
|
+
* @returns {boolean}
|
|
13
|
+
*/
|
|
14
|
+
function isBlankLine(str) {
|
|
15
|
+
const trimmed = str.trim();
|
|
16
|
+
return trimmed === '' || ZERO_WIDTH_RE.test(trimmed);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @description Inline tag to markdown syntax mapping
|
|
21
|
+
*/
|
|
22
|
+
const INLINE_WRAP_MAP = {
|
|
23
|
+
strong: '**',
|
|
24
|
+
b: '**',
|
|
25
|
+
em: '*',
|
|
26
|
+
i: '*',
|
|
27
|
+
del: '~~',
|
|
28
|
+
s: '~~',
|
|
29
|
+
mark: '==',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @description Process children of a JSON node into inline markdown
|
|
34
|
+
* @param {Array<Object>} children
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
function childrenToInline(children) {
|
|
38
|
+
if (!children || children.length === 0) return '';
|
|
39
|
+
|
|
40
|
+
const parts = [];
|
|
41
|
+
for (let i = 0; i < children.length; i++) {
|
|
42
|
+
const part = nodeToMarkdown(children[i], '', false);
|
|
43
|
+
if (part) {
|
|
44
|
+
// Add space between adjacent parts when both are non-empty and
|
|
45
|
+
// the previous part doesn't end with space and current doesn't start with space
|
|
46
|
+
if (parts.length > 0) {
|
|
47
|
+
const prev = parts[parts.length - 1];
|
|
48
|
+
if (prev && !prev.endsWith(' ') && !prev.endsWith('\n') && !part.startsWith(' ') && !part.startsWith('\n')) {
|
|
49
|
+
parts.push(' ');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
parts.push(part);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return parts.join('');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @description Get text content from a JSON node recursively
|
|
60
|
+
* @param {Object} node
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
function getTextContent(node) {
|
|
64
|
+
if (!node) return '';
|
|
65
|
+
if (node.type === 'text') return node.content || '';
|
|
66
|
+
if (node.children) return node.children.map(getTextContent).join('');
|
|
67
|
+
return '';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @description Convert a JSON element node to its HTML string (for fallback)
|
|
72
|
+
* @param {Object} node
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
function nodeToHtmlFallback(node) {
|
|
76
|
+
if (!node) return '';
|
|
77
|
+
if (node.type === 'text') return node.content || '';
|
|
78
|
+
|
|
79
|
+
const { tag, attributes = {}, children = [] } = node;
|
|
80
|
+
const attrStr = Object.entries(attributes)
|
|
81
|
+
.map(([k, v]) => `${k}="${v}"`)
|
|
82
|
+
.join(' ');
|
|
83
|
+
const openTag = attrStr ? `<${tag} ${attrStr}>` : `<${tag}>`;
|
|
84
|
+
|
|
85
|
+
if (VOID_ELEMENTS.test(tag)) return openTag;
|
|
86
|
+
|
|
87
|
+
const inner = children.map(nodeToHtmlFallback).join('');
|
|
88
|
+
return `${openTag}${inner}</${tag}>`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @description Indent each line of a block string
|
|
93
|
+
* @param {string} str
|
|
94
|
+
* @param {string} indent
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
function indentBlock(str, indent) {
|
|
98
|
+
if (!str) return '';
|
|
99
|
+
return (
|
|
100
|
+
str
|
|
101
|
+
.split('\n')
|
|
102
|
+
.map((line) => (line ? indent + line : ''))
|
|
103
|
+
.join('\n') + '\n'
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @description Convert a list JSON node (ul/ol) to markdown
|
|
109
|
+
* @param {Object} node
|
|
110
|
+
* @param {string} indent Current indentation
|
|
111
|
+
* @param {boolean} isOrdered
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
function listToMarkdown(node, indent, isOrdered) {
|
|
115
|
+
const children = node.children || [];
|
|
116
|
+
let result = '';
|
|
117
|
+
let index = 1;
|
|
118
|
+
|
|
119
|
+
for (const child of children) {
|
|
120
|
+
if (child.type !== 'element' || child.tag !== 'li') continue;
|
|
121
|
+
|
|
122
|
+
const prefix = isOrdered ? `${index}. ` : '- ';
|
|
123
|
+
const liChildren = child.children || [];
|
|
124
|
+
let inlineContent = '';
|
|
125
|
+
let subBlocks = '';
|
|
126
|
+
const blockIndent = indent + ' '.repeat(prefix.length);
|
|
127
|
+
|
|
128
|
+
// GFM task list: check for checkbox input
|
|
129
|
+
let taskPrefix = '';
|
|
130
|
+
const firstChild = liChildren[0];
|
|
131
|
+
if (firstChild && firstChild.type === 'element' && firstChild.tag === 'input' && firstChild.attributes?.type === 'checkbox') {
|
|
132
|
+
taskPrefix = firstChild.attributes.checked !== undefined ? '[x] ' : '[ ] ';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (let ci = 0; ci < liChildren.length; ci++) {
|
|
136
|
+
const liChild = liChildren[ci];
|
|
137
|
+
// Skip checkbox input already handled
|
|
138
|
+
if (ci === 0 && taskPrefix) continue;
|
|
139
|
+
|
|
140
|
+
if (liChild.type !== 'element') {
|
|
141
|
+
inlineContent += nodeToMarkdown(liChild, '', false);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const tag = liChild.tag;
|
|
146
|
+
if (tag === 'ul' || tag === 'ol') {
|
|
147
|
+
subBlocks += listToMarkdown(liChild, blockIndent, tag === 'ol');
|
|
148
|
+
} else if (tag === 'table') {
|
|
149
|
+
subBlocks += indentBlock(tableToMarkdown(liChild), blockIndent);
|
|
150
|
+
} else if (tag === 'figure') {
|
|
151
|
+
// figure wraps table/image in editor (e.g. <figure><table>...</table></figure>)
|
|
152
|
+
const tableChild = (liChild.children || []).find((c) => c.type === 'element' && c.tag === 'table');
|
|
153
|
+
if (tableChild) {
|
|
154
|
+
subBlocks += indentBlock(tableToMarkdown(tableChild), blockIndent);
|
|
155
|
+
} else {
|
|
156
|
+
subBlocks += indentBlock(nodeToMarkdown(liChild, '', true), blockIndent);
|
|
157
|
+
}
|
|
158
|
+
} else if (tag === 'blockquote' || tag === 'pre') {
|
|
159
|
+
subBlocks += indentBlock(nodeToMarkdown(liChild, '', true), blockIndent);
|
|
160
|
+
} else {
|
|
161
|
+
inlineContent += nodeToMarkdown(liChild, '', false);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
result += `${indent}${prefix}${taskPrefix}${inlineContent.trim()}\n`;
|
|
166
|
+
if (subBlocks) result += subBlocks;
|
|
167
|
+
index++;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @description Convert a table JSON node to markdown pipe table
|
|
175
|
+
* @param {Object} node
|
|
176
|
+
* @returns {string}
|
|
177
|
+
*/
|
|
178
|
+
function tableToMarkdown(node) {
|
|
179
|
+
const rawRows = [];
|
|
180
|
+
let hasHeader = false;
|
|
181
|
+
|
|
182
|
+
// Collect raw rows from thead/tbody/tfoot or direct tr children
|
|
183
|
+
function collectRows(parent, isHead) {
|
|
184
|
+
for (const child of parent.children || []) {
|
|
185
|
+
if (child.tag === 'thead') {
|
|
186
|
+
collectRows(child, true);
|
|
187
|
+
} else if (child.tag === 'tbody' || child.tag === 'tfoot') {
|
|
188
|
+
collectRows(child, false);
|
|
189
|
+
} else if (child.tag === 'tr') {
|
|
190
|
+
const cells = [];
|
|
191
|
+
for (const cell of child.children || []) {
|
|
192
|
+
if (cell.tag === 'td' || cell.tag === 'th') {
|
|
193
|
+
const content = childrenToInline(cell.children).trim();
|
|
194
|
+
const colspan = parseInt(cell.attributes?.colspan, 10) || 1;
|
|
195
|
+
const rowspan = parseInt(cell.attributes?.rowspan, 10) || 1;
|
|
196
|
+
cells.push({ content, colspan, rowspan });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const isHeaderRow = isHead || (!hasHeader && child.children?.some((c) => c.tag === 'th'));
|
|
200
|
+
if (isHeaderRow) hasHeader = true;
|
|
201
|
+
rawRows.push({ cells, isHeader: isHeaderRow });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
collectRows(node, false);
|
|
207
|
+
if (rawRows.length === 0) return '';
|
|
208
|
+
|
|
209
|
+
// Move header rows to front
|
|
210
|
+
const headerRows = rawRows.filter((r) => r.isHeader);
|
|
211
|
+
const bodyRows = rawRows.filter((r) => !r.isHeader);
|
|
212
|
+
const orderedRows = [...headerRows, ...bodyRows];
|
|
213
|
+
|
|
214
|
+
// Determine column count by expanding colspan
|
|
215
|
+
let colCount = 0;
|
|
216
|
+
for (const row of orderedRows) {
|
|
217
|
+
let count = 0;
|
|
218
|
+
for (const cell of row.cells) {
|
|
219
|
+
count += cell.colspan;
|
|
220
|
+
}
|
|
221
|
+
if (count > colCount) colCount = count;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Build a 2D grid, expanding colspan and rowspan
|
|
225
|
+
// null = unoccupied, string = occupied (content or empty string for spanned cells)
|
|
226
|
+
const grid = Array.from({ length: orderedRows.length }, () => new Array(colCount).fill(null));
|
|
227
|
+
|
|
228
|
+
for (let r = 0; r < orderedRows.length; r++) {
|
|
229
|
+
let col = 0;
|
|
230
|
+
for (const cell of orderedRows[r].cells) {
|
|
231
|
+
// Skip columns already occupied by rowspan from above
|
|
232
|
+
while (col < colCount && grid[r][col] !== null) col++;
|
|
233
|
+
if (col >= colCount) break;
|
|
234
|
+
|
|
235
|
+
// Fill colspan and rowspan cells
|
|
236
|
+
for (let rs = 0; rs < cell.rowspan && r + rs < orderedRows.length; rs++) {
|
|
237
|
+
for (let cs = 0; cs < cell.colspan && col + cs < colCount; cs++) {
|
|
238
|
+
// Only put content in the first cell; spanned cells get empty string
|
|
239
|
+
grid[r + rs][col + cs] = rs === 0 && cs === 0 ? cell.content : '';
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
col += cell.colspan;
|
|
243
|
+
}
|
|
244
|
+
// Fill any remaining null cells with empty string
|
|
245
|
+
for (let c = 0; c < colCount; c++) {
|
|
246
|
+
if (grid[r][c] === null) grid[r][c] = '';
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let result = '';
|
|
251
|
+
const hasHeaderRow = headerRows.length > 0;
|
|
252
|
+
|
|
253
|
+
if (hasHeaderRow) {
|
|
254
|
+
// Use actual header row
|
|
255
|
+
result += '| ' + grid[0].join(' | ') + ' |\n';
|
|
256
|
+
result += '| ' + grid[0].map(() => '---').join(' | ') + ' |\n';
|
|
257
|
+
for (let r = 1; r < grid.length; r++) {
|
|
258
|
+
result += '| ' + grid[r].join(' | ') + ' |\n';
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
// No header: insert empty header row
|
|
262
|
+
result += '| ' + new Array(colCount).fill(' ').join(' | ') + ' |\n';
|
|
263
|
+
result += '| ' + new Array(colCount).fill('---').join(' | ') + ' |\n';
|
|
264
|
+
for (let r = 0; r < grid.length; r++) {
|
|
265
|
+
result += '| ' + grid[r].join(' | ') + ' |\n';
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* @description Convert a single JSON node to markdown
|
|
274
|
+
* @param {Object} node JSON node from htmlToJson
|
|
275
|
+
* @param {string} indent Current indentation for block-level
|
|
276
|
+
* @param {boolean} isBlock Whether we're in block context
|
|
277
|
+
* @returns {string}
|
|
278
|
+
*/
|
|
279
|
+
function nodeToMarkdown(node, indent, isBlock) {
|
|
280
|
+
if (!node) return '';
|
|
281
|
+
|
|
282
|
+
// Text node - strip zero-width characters (editor artifacts)
|
|
283
|
+
if (node.type === 'text') {
|
|
284
|
+
return (node.content || '').replace(/[\u200B\uFEFF]/g, '');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (node.type !== 'element') return '';
|
|
288
|
+
|
|
289
|
+
const { tag, attributes = {}, children = [] } = node;
|
|
290
|
+
|
|
291
|
+
// Body (root node from htmlToJson)
|
|
292
|
+
if (tag === 'body') {
|
|
293
|
+
return children.map((c) => nodeToMarkdown(c, '', true)).join('');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Headings
|
|
297
|
+
const headingMatch = /^h([1-6])$/.exec(tag);
|
|
298
|
+
if (headingMatch) {
|
|
299
|
+
const level = parseInt(headingMatch[1], 10);
|
|
300
|
+
const prefix = '#'.repeat(level);
|
|
301
|
+
return `${prefix} ${childrenToInline(children).trim()}\n\n`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (tag === 'p') {
|
|
305
|
+
const content = childrenToInline(children);
|
|
306
|
+
// Empty paragraph
|
|
307
|
+
if (!content.trim()) return '\n';
|
|
308
|
+
return `${content.trim()}\n\n`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (tag === 'br') {
|
|
312
|
+
return '\n';
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (tag === 'hr') {
|
|
316
|
+
return '---\n\n';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Inline formatting
|
|
320
|
+
const wrapSyntax = INLINE_WRAP_MAP[tag];
|
|
321
|
+
if (wrapSyntax) {
|
|
322
|
+
const content = childrenToInline(children);
|
|
323
|
+
return `${wrapSyntax}${content}${wrapSyntax}`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Inline code
|
|
327
|
+
if (tag === 'code') {
|
|
328
|
+
const content = getTextContent({ children });
|
|
329
|
+
// Use double backticks if content contains backtick
|
|
330
|
+
if (content.includes('`')) {
|
|
331
|
+
return '`` ' + content + ' ``';
|
|
332
|
+
}
|
|
333
|
+
return '`' + content + '`';
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Keyboard input
|
|
337
|
+
if (tag === 'kbd') {
|
|
338
|
+
const content = getTextContent({ children });
|
|
339
|
+
return `<kbd>${content}</kbd>`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Subscript / Superscript
|
|
343
|
+
if (tag === 'sub') {
|
|
344
|
+
const content = childrenToInline(children);
|
|
345
|
+
return `<sub>${content}</sub>`;
|
|
346
|
+
}
|
|
347
|
+
if (tag === 'sup') {
|
|
348
|
+
const content = childrenToInline(children);
|
|
349
|
+
return `<sup>${content}</sup>`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (tag === 'a') {
|
|
353
|
+
const href = attributes.href || '';
|
|
354
|
+
const text = childrenToInline(children);
|
|
355
|
+
const title = attributes.title;
|
|
356
|
+
if (title) return `[${text}](${href} "${title}")`;
|
|
357
|
+
return `[${text}](${href})`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (tag === 'img') {
|
|
361
|
+
const src = attributes.src || '';
|
|
362
|
+
const alt = attributes.alt || '';
|
|
363
|
+
return ``;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (tag === 'blockquote') {
|
|
367
|
+
const inner = children.map((c) => nodeToMarkdown(c, '', true)).join('');
|
|
368
|
+
// Prefix each line with >
|
|
369
|
+
const lines = inner.replace(/\n$/, '').split('\n');
|
|
370
|
+
return lines.map((line) => `> ${line}`).join('\n') + '\n\n';
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Pre / code block
|
|
374
|
+
if (tag === 'pre') {
|
|
375
|
+
let lang = '';
|
|
376
|
+
let codeContent = '';
|
|
377
|
+
|
|
378
|
+
// Check if pre contains a code element
|
|
379
|
+
const codeChild = children.find((c) => c.type === 'element' && c.tag === 'code');
|
|
380
|
+
if (codeChild) {
|
|
381
|
+
lang = (codeChild.attributes?.class || '').replace(/^language-/, '');
|
|
382
|
+
codeContent = getTextContent(codeChild);
|
|
383
|
+
} else {
|
|
384
|
+
// Also check pre's own class for language- (editor internal format)
|
|
385
|
+
lang = (node.attributes?.class || '').match(/language-(\S+)/)?.[1] || '';
|
|
386
|
+
codeContent = getTextContent({ children });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Replace <br> represented as newlines
|
|
390
|
+
return '```' + lang + '\n' + codeContent + '\n```\n\n';
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Details/Summary (GFM)
|
|
394
|
+
if (tag === 'details') {
|
|
395
|
+
return nodeToHtmlFallback(node) + '\n\n';
|
|
396
|
+
}
|
|
397
|
+
if (tag === 'summary') {
|
|
398
|
+
return nodeToHtmlFallback(node);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Lists
|
|
402
|
+
if (tag === 'ul') {
|
|
403
|
+
return listToMarkdown(node, indent, false) + '\n';
|
|
404
|
+
}
|
|
405
|
+
if (tag === 'ol') {
|
|
406
|
+
return listToMarkdown(node, indent, true) + '\n';
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (tag === 'table') {
|
|
410
|
+
return tableToMarkdown(node) + '\n';
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (tag === 'dl') {
|
|
414
|
+
let result = '';
|
|
415
|
+
for (const child of children) {
|
|
416
|
+
if (child.type !== 'element') continue;
|
|
417
|
+
if (child.tag === 'dt') {
|
|
418
|
+
result += childrenToInline(child.children).trim() + '\n';
|
|
419
|
+
} else if (child.tag === 'dd') {
|
|
420
|
+
result += ': ' + childrenToInline(child.children).trim() + '\n';
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return result + '\n';
|
|
424
|
+
}
|
|
425
|
+
if (tag === 'dt' || tag === 'dd') {
|
|
426
|
+
return childrenToInline(children).trim();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Div - check for component containers, otherwise process children
|
|
430
|
+
if (tag === 'div') {
|
|
431
|
+
// Component containers - process inner content
|
|
432
|
+
if (attributes.class && /se-component/.test(attributes.class)) {
|
|
433
|
+
return children.map((c) => nodeToMarkdown(c, indent, true)).join('');
|
|
434
|
+
}
|
|
435
|
+
const content = childrenToInline(children);
|
|
436
|
+
if (isBlock) return content + '\n\n';
|
|
437
|
+
return content;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Span - pass through as inline (may contain styles)
|
|
441
|
+
if (tag === 'span') {
|
|
442
|
+
// Check if it has meaningful attributes that need HTML fallback
|
|
443
|
+
if (attributes.style || attributes.class) {
|
|
444
|
+
return nodeToHtmlFallback(node);
|
|
445
|
+
}
|
|
446
|
+
return childrenToInline(children);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Figure - process children (usually contains img or other media)
|
|
450
|
+
if (tag === 'figure') {
|
|
451
|
+
return children.map((c) => nodeToMarkdown(c, indent, true)).join('');
|
|
452
|
+
}
|
|
453
|
+
if (tag === 'figcaption') {
|
|
454
|
+
const content = childrenToInline(children).trim();
|
|
455
|
+
return content ? content + '\n\n' : '';
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Video / Audio / Iframe - fallback to HTML
|
|
459
|
+
if (tag === 'video' || tag === 'audio' || tag === 'iframe') {
|
|
460
|
+
return nodeToHtmlFallback(node) + '\n\n';
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Abbreviation
|
|
464
|
+
if (tag === 'abbr') {
|
|
465
|
+
return nodeToHtmlFallback(node);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// HTML fallback
|
|
469
|
+
return nodeToHtmlFallback(node);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* @description Converts a JSON tree (from htmlToJson) to a Markdown string.
|
|
474
|
+
* @param {Object} jsonNode JSON node from htmlToJson
|
|
475
|
+
* @returns {string} Markdown string
|
|
476
|
+
* @example
|
|
477
|
+
* const json = htmlToJson('<p><strong>Hello</strong> World</p>');
|
|
478
|
+
* const md = jsonToMarkdown(json);
|
|
479
|
+
* // '**Hello** World\n\n'
|
|
480
|
+
*/
|
|
481
|
+
export function jsonToMarkdown(jsonNode) {
|
|
482
|
+
if (!jsonNode) return '';
|
|
483
|
+
const result = nodeToMarkdown(jsonNode, '', true);
|
|
484
|
+
// Trim trailing whitespace but keep final newline
|
|
485
|
+
return result.replace(/\n{3,}/g, '\n\n').trim() + '\n';
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ========================================================================
|
|
489
|
+
// Markdown -> HTML Parser
|
|
490
|
+
// ========================================================================
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* @description Parse inline markdown syntax to HTML
|
|
494
|
+
* @param {string} text
|
|
495
|
+
* @returns {string} HTML string
|
|
496
|
+
*/
|
|
497
|
+
function parseInline(text) {
|
|
498
|
+
if (!text) return '';
|
|
499
|
+
|
|
500
|
+
const placeholders = [];
|
|
501
|
+
function hold(html) {
|
|
502
|
+
placeholders.push(html);
|
|
503
|
+
return '\uFFFC' + (placeholders.length - 1) + '\uFFFC';
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// 1. Escape sequences: \* \_ \~ \` \[ \] \( \) \# \! \\ \| \{ \} \< \> \+ \-
|
|
507
|
+
text = text.replace(/\\([\\*_~`[\]()#!|{}<>+-])/g, (_, ch) => hold(ch));
|
|
508
|
+
|
|
509
|
+
// 2. Inline code — extract to placeholder so inner syntax is not parsed
|
|
510
|
+
text = text.replace(/``\s(.+?)\s``/g, (_, c) => hold('<code>' + c + '</code>'));
|
|
511
|
+
text = text.replace(/`([^`]+)`/g, (_, c) => hold('<code>' + c + '</code>'));
|
|
512
|
+
|
|
513
|
+
// 3. Images:  or 
|
|
514
|
+
text = text.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (_, alt, src, title) => {
|
|
515
|
+
return hold(title ? `<img src="${src}" alt="${alt}" title="${title}">` : `<img src="${src}" alt="${alt}">`);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// 4. Links: [text](url "title") or [text](url)
|
|
519
|
+
text = text.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (_, t, url, title) => {
|
|
520
|
+
return hold(title ? `<a href="${url}" title="${title}">${t}</a>` : `<a href="${url}">${t}</a>`);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// 5. Autolinks: <https://url> or <email@example.com>
|
|
524
|
+
text = text.replace(/<(https?:\/\/[^>]+)>/g, (_, url) => hold(`<a href="${url}">${url}</a>`));
|
|
525
|
+
text = text.replace(/<([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})>/g, (_, email) => hold(`<a href="mailto:${email}">${email}</a>`));
|
|
526
|
+
|
|
527
|
+
// 6. GFM autolink: bare URLs (only when not already inside a tag attribute)
|
|
528
|
+
text = text.replace(/(?<![="'\w/])(https?:\/\/[^\s<>\])"]+)/g, (_, url) => hold(`<a href="${url}">${url}</a>`));
|
|
529
|
+
|
|
530
|
+
// 7. Strikethrough first (before single tilde subscript)
|
|
531
|
+
text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
|
|
532
|
+
|
|
533
|
+
// 8. Bold+italic: ***text*** or ___text___
|
|
534
|
+
text = text.replace(/\*{3}(.+?)\*{3}/g, '<strong><em>$1</em></strong>');
|
|
535
|
+
text = text.replace(/_{3}(.+?)_{3}/g, '<strong><em>$1</em></strong>');
|
|
536
|
+
|
|
537
|
+
// 9. Bold: **text** or __text__
|
|
538
|
+
text = text.replace(/\*{2}(.+?)\*{2}/g, '<strong>$1</strong>');
|
|
539
|
+
text = text.replace(/_{2}(.+?)_{2}/g, '<strong>$1</strong>');
|
|
540
|
+
|
|
541
|
+
// 10. Italic: *text* or _text_
|
|
542
|
+
text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
|
543
|
+
text = text.replace(/(?<![a-zA-Z0-9])_(.+?)_(?![a-zA-Z0-9])/g, '<em>$1</em>');
|
|
544
|
+
|
|
545
|
+
// 11. GFM highlight: ==text==
|
|
546
|
+
text = text.replace(/==(.+?)==/g, '<mark>$1</mark>');
|
|
547
|
+
|
|
548
|
+
// 12. Superscript: ^text^
|
|
549
|
+
text = text.replace(/\^([^\s^]+)\^/g, '<sup>$1</sup>');
|
|
550
|
+
|
|
551
|
+
// 13. Subscript: ~text~ (single tilde only, after strikethrough is already consumed)
|
|
552
|
+
text = text.replace(/(?<!~)~([^\s~]+)~(?!~)/g, '<sub>$1</sub>');
|
|
553
|
+
|
|
554
|
+
// Restore all placeholders
|
|
555
|
+
text = text.replace(/\uFFFC(\d+)\uFFFC/g, (_, idx) => placeholders[parseInt(idx, 10)]);
|
|
556
|
+
|
|
557
|
+
return text;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* @description Parses a Markdown string into an HTML string.
|
|
562
|
+
* - HTML tags in the markdown are passed through as-is (for fallback elements).
|
|
563
|
+
* @param {string} md Markdown string
|
|
564
|
+
* @param {string} [defaultLine='p'] Default block element tag
|
|
565
|
+
* @returns {string} HTML string
|
|
566
|
+
* @example
|
|
567
|
+
* markdownToHtml('# Hello\n\n**bold** text');
|
|
568
|
+
* // '<h1>Hello</h1><p><strong>bold</strong> text</p>'
|
|
569
|
+
*/
|
|
570
|
+
export function markdownToHtml(md, defaultLine) {
|
|
571
|
+
if (!md) return '';
|
|
572
|
+
if (!defaultLine) defaultLine = 'p';
|
|
573
|
+
|
|
574
|
+
const lines = md.split('\n');
|
|
575
|
+
const output = [];
|
|
576
|
+
let i = 0;
|
|
577
|
+
|
|
578
|
+
while (i < lines.length) {
|
|
579
|
+
const line = lines[i];
|
|
580
|
+
|
|
581
|
+
// Empty line (or zero-width-only line) - skip
|
|
582
|
+
if (isBlankLine(line)) {
|
|
583
|
+
i++;
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Code fence: ``` or ~~~
|
|
588
|
+
const fenceMatch = /^(`{3,}|~{3,})(\w*)/.exec(line);
|
|
589
|
+
if (fenceMatch) {
|
|
590
|
+
const fenceChar = fenceMatch[1].charAt(0);
|
|
591
|
+
const fenceLen = fenceMatch[1].length;
|
|
592
|
+
const lang = fenceMatch[2];
|
|
593
|
+
const codeLines = [];
|
|
594
|
+
i++;
|
|
595
|
+
// Close fence must use same char and at least same length
|
|
596
|
+
const closeRe = new RegExp('^' + (fenceChar === '`' ? '`' : '~') + '{' + fenceLen + ',}\\s*$');
|
|
597
|
+
while (i < lines.length && !closeRe.test(lines[i])) {
|
|
598
|
+
codeLines.push(lines[i]);
|
|
599
|
+
i++;
|
|
600
|
+
}
|
|
601
|
+
i++; // skip closing fence
|
|
602
|
+
const langAttr = lang ? ` class="language-${lang}"` : '';
|
|
603
|
+
output.push(`<pre><code${langAttr}>${escapeHtml(codeLines.join('\n'))}</code></pre>`);
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Heading: # ~ ######
|
|
608
|
+
const headingMatch = /^(#{1,6})\s+(.+)/.exec(line);
|
|
609
|
+
if (headingMatch) {
|
|
610
|
+
const level = headingMatch[1].length;
|
|
611
|
+
output.push(`<h${level}>${parseInline(headingMatch[2].trim())}</h${level}>`);
|
|
612
|
+
i++;
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Horizontal rule: ---, ***, ___
|
|
617
|
+
if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(line)) {
|
|
618
|
+
output.push('<hr>');
|
|
619
|
+
i++;
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Blockquote
|
|
624
|
+
if (/^>\s?/.test(line)) {
|
|
625
|
+
const quoteLines = [];
|
|
626
|
+
while (i < lines.length && /^>\s?/.test(lines[i])) {
|
|
627
|
+
quoteLines.push(lines[i].replace(/^>\s?/, ''));
|
|
628
|
+
i++;
|
|
629
|
+
}
|
|
630
|
+
const innerHtml = markdownToHtml(quoteLines.join('\n'), defaultLine);
|
|
631
|
+
output.push(`<blockquote>${innerHtml}</blockquote>`);
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Table
|
|
636
|
+
if (/^\|.*\|/.test(line) && i + 1 < lines.length && /^\|[\s:]*-{3,}[\s:]*/.test(lines[i + 1])) {
|
|
637
|
+
const tableLines = [];
|
|
638
|
+
while (i < lines.length && /^\|.*\|/.test(lines[i])) {
|
|
639
|
+
tableLines.push(lines[i]);
|
|
640
|
+
i++;
|
|
641
|
+
}
|
|
642
|
+
output.push(parseTable(tableLines));
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Unordered list: - item, * item, + item
|
|
647
|
+
if (/^(\s*)([-*+])\s+/.test(line)) {
|
|
648
|
+
const result = parseList(lines, i, false);
|
|
649
|
+
output.push(result.html);
|
|
650
|
+
i = result.index;
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Ordered list: 1. item
|
|
655
|
+
if (/^(\s*)\d+\.\s+/.test(line)) {
|
|
656
|
+
const result = parseList(lines, i, true);
|
|
657
|
+
output.push(result.html);
|
|
658
|
+
i = result.index;
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// HTML tag pass-through (starts with <)
|
|
663
|
+
if (/^\s*<[a-zA-Z]/.test(line)) {
|
|
664
|
+
// Collect all lines until we find the closing or a blank line
|
|
665
|
+
let htmlBlock = line;
|
|
666
|
+
i++;
|
|
667
|
+
// If it's a self-closing or known void element, just pass it
|
|
668
|
+
if (VOID_ELEMENTS.test(line.match(/<(\w+)/)?.[1] || '')) {
|
|
669
|
+
output.push(htmlBlock);
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
// For block-level HTML, collect until blank line
|
|
673
|
+
while (i < lines.length && !isBlankLine(lines[i])) {
|
|
674
|
+
htmlBlock += '\n' + lines[i];
|
|
675
|
+
i++;
|
|
676
|
+
}
|
|
677
|
+
output.push(htmlBlock);
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Paragraph (default)
|
|
682
|
+
const paraLines = [];
|
|
683
|
+
while (i < lines.length && !isBlankLine(lines[i]) && !/^(#{1,6}\s|`{3,}|~{3,}|>|\||(\s*)([-*+]|\d+\.)\s|(\*{3,}|-{3,}|_{3,})\s*$)/.test(lines[i])) {
|
|
684
|
+
paraLines.push(lines[i]);
|
|
685
|
+
i++;
|
|
686
|
+
}
|
|
687
|
+
if (paraLines.length > 0) {
|
|
688
|
+
const content = parseInline(paraLines.join('\n'));
|
|
689
|
+
output.push(`<${defaultLine}>${content}</${defaultLine}>`);
|
|
690
|
+
} else {
|
|
691
|
+
// Line matched a block-break pattern but no handler caught it (e.g., "|" without table separator)
|
|
692
|
+
output.push(`<${defaultLine}>${parseInline(lines[i])}</${defaultLine}>`);
|
|
693
|
+
i++;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
return output.join('');
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* @description Parse markdown table lines into HTML table
|
|
702
|
+
* @param {string[]} tableLines
|
|
703
|
+
* @returns {string}
|
|
704
|
+
*/
|
|
705
|
+
function parseTable(tableLines) {
|
|
706
|
+
if (tableLines.length < 2) return '';
|
|
707
|
+
|
|
708
|
+
const parseRow = (line) =>
|
|
709
|
+
line
|
|
710
|
+
.replace(/^\|/, '')
|
|
711
|
+
.replace(/\|$/, '')
|
|
712
|
+
.split('|')
|
|
713
|
+
.map((c) => c.trim());
|
|
714
|
+
|
|
715
|
+
const headers = parseRow(tableLines[0]);
|
|
716
|
+
// Parse separator line for alignment
|
|
717
|
+
const separators = parseRow(tableLines[1]);
|
|
718
|
+
const aligns = separators.map((sep) => {
|
|
719
|
+
const trimmed = sep.trim();
|
|
720
|
+
if (/^:-+:$/.test(trimmed)) return 'center';
|
|
721
|
+
if (/^-+:$/.test(trimmed)) return 'right';
|
|
722
|
+
if (/^:-+$/.test(trimmed)) return 'left';
|
|
723
|
+
return '';
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
const rows = tableLines.slice(2).map(parseRow);
|
|
727
|
+
const colCount = headers.length;
|
|
728
|
+
|
|
729
|
+
// Check if header row is empty (all cells blank)
|
|
730
|
+
const isEmptyHeader = headers.every((h) => h === '');
|
|
731
|
+
|
|
732
|
+
let html = '<table>';
|
|
733
|
+
|
|
734
|
+
const alignAttr = (idx) => {
|
|
735
|
+
const a = aligns[idx];
|
|
736
|
+
return a ? ` style="text-align: ${a};"` : '';
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
if (!isEmptyHeader) {
|
|
740
|
+
html += '<thead><tr>';
|
|
741
|
+
for (let c = 0; c < colCount; c++) {
|
|
742
|
+
html += `<th${alignAttr(c)}>${parseInline(headers[c])}</th>`;
|
|
743
|
+
}
|
|
744
|
+
html += '</tr></thead>';
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (rows.length > 0 || isEmptyHeader) {
|
|
748
|
+
html += '<tbody>';
|
|
749
|
+
for (const row of rows) {
|
|
750
|
+
html += '<tr>';
|
|
751
|
+
for (let c = 0; c < colCount; c++) {
|
|
752
|
+
html += `<td${alignAttr(c)}>${parseInline(row[c] || '')}</td>`;
|
|
753
|
+
}
|
|
754
|
+
html += '</tr>';
|
|
755
|
+
}
|
|
756
|
+
html += '</tbody>';
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
html += '</table>';
|
|
760
|
+
return html;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* @description Parse markdown list into HTML
|
|
765
|
+
* @param {string[]} lines
|
|
766
|
+
* @param {number} startIndex
|
|
767
|
+
* @param {boolean} ordered
|
|
768
|
+
* @returns {{ html: string, index: number }}
|
|
769
|
+
*/
|
|
770
|
+
function parseList(lines, startIndex, ordered) {
|
|
771
|
+
const tag = ordered ? 'ol' : 'ul';
|
|
772
|
+
const itemPattern = ordered ? /^(\s*)\d+\.\s+(.*)/ : /^(\s*)([-*+])\s+(.*)/;
|
|
773
|
+
let html = `<${tag}>`;
|
|
774
|
+
let i = startIndex;
|
|
775
|
+
const baseIndent = (lines[i].match(/^(\s*)/)[1] || '').length;
|
|
776
|
+
|
|
777
|
+
while (i < lines.length) {
|
|
778
|
+
const line = lines[i];
|
|
779
|
+
if (isBlankLine(line)) {
|
|
780
|
+
i++;
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const currentIndent = (line.match(/^(\s*)/)[1] || '').length;
|
|
785
|
+
|
|
786
|
+
// If less indented than base, we're done
|
|
787
|
+
if (currentIndent < baseIndent) break;
|
|
788
|
+
|
|
789
|
+
// If at base level, check if it matches our list pattern
|
|
790
|
+
if (currentIndent === baseIndent) {
|
|
791
|
+
const match = itemPattern.exec(line);
|
|
792
|
+
if (!match) break;
|
|
793
|
+
|
|
794
|
+
const content = ordered ? line.replace(/^\s*\d+\.\s+/, '') : line.replace(/^\s*[-*+]\s+/, '');
|
|
795
|
+
i++;
|
|
796
|
+
|
|
797
|
+
// GFM task list checkbox
|
|
798
|
+
let isTask = false;
|
|
799
|
+
let taskChecked = false;
|
|
800
|
+
let displayContent = content;
|
|
801
|
+
const taskMatch = /^\[([ xX])\]\s*(.*)/.exec(content);
|
|
802
|
+
if (taskMatch) {
|
|
803
|
+
isTask = true;
|
|
804
|
+
taskChecked = taskMatch[1] !== ' ';
|
|
805
|
+
displayContent = taskMatch[2];
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Collect nested blocks (sublists, tables, etc.) that are more indented
|
|
809
|
+
let nestedHtml = '';
|
|
810
|
+
while (i < lines.length) {
|
|
811
|
+
const nextLine = lines[i];
|
|
812
|
+
if (isBlankLine(nextLine)) {
|
|
813
|
+
i++;
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
const nextIndent = (nextLine.match(/^(\s*)/)[1] || '').length;
|
|
817
|
+
if (nextIndent <= baseIndent) break;
|
|
818
|
+
|
|
819
|
+
// Nested list
|
|
820
|
+
if (/^\s*[-*+]\s+/.test(nextLine) || /^\s*\d+\.\s+/.test(nextLine)) {
|
|
821
|
+
const nested = parseList(lines, i, /^\s*\d+\.\s+/.test(nextLine));
|
|
822
|
+
nestedHtml += nested.html;
|
|
823
|
+
i = nested.index;
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Nested table (indented pipe table)
|
|
828
|
+
const trimmedLine = nextLine.trimStart();
|
|
829
|
+
if (/^\|.*\|/.test(trimmedLine) && i + 1 < lines.length && /^\s*\|[\s:]*-{3,}/.test(lines[i + 1])) {
|
|
830
|
+
const tableLines = [];
|
|
831
|
+
while (i < lines.length) {
|
|
832
|
+
const tl = lines[i];
|
|
833
|
+
if (isBlankLine(tl) || (tl.match(/^(\s*)/)[1] || '').length <= baseIndent) break;
|
|
834
|
+
if (!/^\s*\|/.test(tl)) break;
|
|
835
|
+
tableLines.push(tl.trimStart());
|
|
836
|
+
i++;
|
|
837
|
+
}
|
|
838
|
+
nestedHtml += parseTable(tableLines);
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Other indented content — treat as continuation
|
|
843
|
+
break;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
if (isTask) {
|
|
847
|
+
const checkbox = taskChecked ? '<input type="checkbox" checked disabled> ' : '<input type="checkbox" disabled> ';
|
|
848
|
+
html += `<li class="task-list-item">${checkbox}${parseInline(displayContent)}${nestedHtml}</li>`;
|
|
849
|
+
} else {
|
|
850
|
+
html += `<li>${parseInline(content)}${nestedHtml}</li>`;
|
|
851
|
+
}
|
|
852
|
+
} else {
|
|
853
|
+
// More indented - this shouldn't happen if nested blocks are handled above
|
|
854
|
+
break;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
html += `</${tag}>`;
|
|
859
|
+
return { html, index: i };
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* @description Escape HTML special characters
|
|
864
|
+
* @param {string} str
|
|
865
|
+
* @returns {string}
|
|
866
|
+
*/
|
|
867
|
+
function escapeHtml(str) {
|
|
868
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const markdown = {
|
|
872
|
+
jsonToMarkdown,
|
|
873
|
+
markdownToHtml,
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
export default markdown;
|