@puredesktop/platform-editor 1.0.0-beta.1 → 2.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/package.json +1 -1
- package/src/CollapsePanel.tsx +35 -35
- package/src/DocumentDiffView.tsx +130 -130
- package/src/DocumentEditor.test.ts +118 -118
- package/src/DocumentEditor.tsx +2651 -2631
- package/src/alignedLineDiff.test.ts +52 -52
- package/src/alignedLineDiff.ts +67 -67
- package/src/collectionAssetSrc.ts +2 -2
- package/src/commentUtils.test.ts +350 -350
- package/src/commentUtils.ts +546 -546
- package/src/constants/toolKeys.ts +14 -14
- package/src/contentFormat.test.ts +27 -27
- package/src/contentFormat.ts +18 -18
- package/src/editorExtensions.ts +155 -155
- package/src/extensions/appliedChangeMark.ts +115 -115
- package/src/extensions/autoReviewPrompts.test.ts +529 -529
- package/src/extensions/autoReviewPrompts.ts +1442 -1442
- package/src/extensions/collectionImage.ts +26 -26
- package/src/extensions/collectionImagePaste.ts +215 -215
- package/src/extensions/commentMark.ts +223 -223
- package/src/extensions/documentAssetIdentity.ts +54 -54
- package/src/extensions/figure.ts +92 -92
- package/src/extensions/footnote.ts +186 -186
- package/src/extensions/indexMarker.ts +88 -88
- package/src/extensions/mathEditing.ts +239 -239
- package/src/extensions/mermaidBlock.tsx +297 -297
- package/src/extensions/slashCommands.test.ts +170 -170
- package/src/extensions/slashCommands.ts +746 -746
- package/src/extensions/smartTypography.test.ts +74 -74
- package/src/extensions/smartTypography.ts +120 -120
- package/src/index.ts +138 -137
- package/src/insertCollectionImage.test.ts +60 -60
- package/src/insertCollectionImage.ts +63 -63
- package/src/insertInlineAssetKind.test.ts +67 -67
- package/src/insertInlineAssetKind.ts +49 -49
- package/src/mermaidPreview.test.ts +54 -54
- package/src/mermaidPreview.ts +115 -115
- package/src/styled.ts +697 -676
- package/src/toolbar/ToolBtn.tsx +77 -63
- package/src/toolbar/Toolbar.test.tsx +325 -325
- package/src/toolbar/Toolbar.tsx +662 -624
- package/src/toolbar/groups/HeadingGroup.tsx +175 -153
- package/src/toolbar/overlayTypes.ts +28 -28
- package/src/useEditorExtensions.ts +22 -22
- package/src/utils/markdownUtils.ts +331 -331
|
@@ -1,331 +1,331 @@
|
|
|
1
|
-
import { marked } from 'marked'
|
|
2
|
-
import TurndownService from 'turndown'
|
|
3
|
-
|
|
4
|
-
const turndownService = new TurndownService({
|
|
5
|
-
headingStyle: 'atx',
|
|
6
|
-
codeBlockStyle: 'fenced',
|
|
7
|
-
emDelimiter: '*',
|
|
8
|
-
strongDelimiter: '**',
|
|
9
|
-
bulletListMarker: '-',
|
|
10
|
-
hr: '---',
|
|
11
|
-
})
|
|
12
|
-
|
|
13
|
-
// Clean up whitespace in paragraphs and divs
|
|
14
|
-
turndownService.addRule('cleanWhitespace', {
|
|
15
|
-
filter: (node): boolean => node.nodeName === 'P' || node.nodeName === 'DIV',
|
|
16
|
-
replacement: (content: string): string => `${content.trim()}\n\n`,
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
turndownService.addRule('taskCheckbox', {
|
|
20
|
-
filter: (node): boolean =>
|
|
21
|
-
node.nodeName === 'INPUT' && (node as HTMLInputElement).type === 'checkbox',
|
|
22
|
-
replacement: (_content: string, node): string =>
|
|
23
|
-
(node as HTMLInputElement).checked ? '[x] ' : '[ ] ',
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
turndownService.addRule('taskItem', {
|
|
27
|
-
filter: (node): boolean =>
|
|
28
|
-
node.nodeName === 'LI' &&
|
|
29
|
-
(node as HTMLElement).dataset?.type === 'taskItem',
|
|
30
|
-
replacement: (content: string, node): string => {
|
|
31
|
-
const element = node as HTMLElement
|
|
32
|
-
const checked =
|
|
33
|
-
element.getAttribute('data-checked') === 'true' ||
|
|
34
|
-
(
|
|
35
|
-
element.querySelector(
|
|
36
|
-
'input[type="checkbox"]',
|
|
37
|
-
) as HTMLInputElement | null
|
|
38
|
-
)?.checked === true
|
|
39
|
-
const prefix = checked ? '- [x] ' : '- [ ] '
|
|
40
|
-
const body = content.replace(/^\s*\[(x| )\]\s*/i, '').trim()
|
|
41
|
-
if (!body) return `${prefix}\n`
|
|
42
|
-
|
|
43
|
-
if (looksLikeMarkdownTable(body)) {
|
|
44
|
-
return `${prefix}\n${indentMarkdown(body)}\n`
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const [firstBlock, ...restBlocks] = body.split(/\n{2,}/)
|
|
48
|
-
const head = `${prefix}${firstBlock.trim()}`
|
|
49
|
-
if (restBlocks.length === 0) return `${head}\n`
|
|
50
|
-
return `${head}\n\n${indentMarkdown(restBlocks.join('\n\n').trim())}\n`
|
|
51
|
-
},
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
turndownService.addRule('table', {
|
|
55
|
-
filter: (node): boolean => node.nodeName === 'TABLE',
|
|
56
|
-
replacement: (_content: string, node): string => {
|
|
57
|
-
const table = node as HTMLTableElement
|
|
58
|
-
const rows = Array.from(table.querySelectorAll('tr')).map(row =>
|
|
59
|
-
Array.from(row.children).map(cell =>
|
|
60
|
-
normalizeCell(cell.textContent ?? ''),
|
|
61
|
-
),
|
|
62
|
-
)
|
|
63
|
-
if (rows.length === 0) return ''
|
|
64
|
-
|
|
65
|
-
const header = rows[0]
|
|
66
|
-
const body = rows.slice(1)
|
|
67
|
-
const separator = header.map(() => '---')
|
|
68
|
-
const lines = [
|
|
69
|
-
`| ${header.join(' | ')} |`,
|
|
70
|
-
`| ${separator.join(' | ')} |`,
|
|
71
|
-
...body.map(row => `| ${padRow(row, header.length).join(' | ')} |`),
|
|
72
|
-
]
|
|
73
|
-
|
|
74
|
-
return `\n\n${lines.join('\n')}\n\n`
|
|
75
|
-
},
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
// Index markers (#172): <a class="ix" data-term="X">term</a> serializes
|
|
79
|
-
// back as raw HTML so the round-trip is lossless. CommonMark passes
|
|
80
|
-
// inline HTML through unchanged.
|
|
81
|
-
turndownService.keep(
|
|
82
|
-
node =>
|
|
83
|
-
node.nodeName === 'A' &&
|
|
84
|
-
(node as HTMLElement).classList?.contains('ix') === true,
|
|
85
|
-
)
|
|
86
|
-
|
|
87
|
-
turndownService.keep(
|
|
88
|
-
node =>
|
|
89
|
-
node.nodeName === 'SPAN' &&
|
|
90
|
-
(node as HTMLElement).hasAttribute?.('data-comment-id') === true,
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
turndownService.keep(node => node.nodeName === 'SUB' || node.nodeName === 'SUP')
|
|
94
|
-
|
|
95
|
-
turndownService.addRule('inlineMath', {
|
|
96
|
-
filter: (node): boolean =>
|
|
97
|
-
node.nodeName === 'SPAN' &&
|
|
98
|
-
(node as HTMLElement).dataset?.type === 'inline-math',
|
|
99
|
-
replacement: (_content: string, node): string => {
|
|
100
|
-
const latex = (node as HTMLElement).getAttribute('data-latex') ?? ''
|
|
101
|
-
return latex ? `$${latex}$` : ''
|
|
102
|
-
},
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
turndownService.addRule('blockMath', {
|
|
106
|
-
filter: (node): boolean =>
|
|
107
|
-
node.nodeName === 'DIV' &&
|
|
108
|
-
(node as HTMLElement).dataset?.type === 'block-math',
|
|
109
|
-
replacement: (_content: string, node): string => {
|
|
110
|
-
const latex = (node as HTMLElement).getAttribute('data-latex') ?? ''
|
|
111
|
-
return latex ? `\n\n$$\n${latex}\n$$\n\n` : ''
|
|
112
|
-
},
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
// <figure><img/><figcaption>caption</figcaption></figure> → 
|
|
116
|
-
// The render pipeline already wraps caption-bearing markdown images in
|
|
117
|
-
// a real <figure>, so this lossy serialization round-trips cleanly for
|
|
118
|
-
// PDF output. The alt attribute is used as the markdown alt text only
|
|
119
|
-
// when the figure has no caption.
|
|
120
|
-
turndownService.addRule('figure', {
|
|
121
|
-
filter: (node): boolean => node.nodeName === 'FIGURE',
|
|
122
|
-
replacement: (_content: string, node): string => {
|
|
123
|
-
const el = node as HTMLElement
|
|
124
|
-
const img = el.querySelector('img')
|
|
125
|
-
const captionEl = el.querySelector('figcaption')
|
|
126
|
-
if (!img) return ''
|
|
127
|
-
const src = img.getAttribute('src') ?? ''
|
|
128
|
-
const alt = img.getAttribute('alt') ?? ''
|
|
129
|
-
const caption = (captionEl?.textContent ?? '').trim()
|
|
130
|
-
const text = caption || alt
|
|
131
|
-
return `\n\n\n\n`
|
|
132
|
-
},
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
/** Convert markdown string to HTML. Returns empty string for falsy input. */
|
|
136
|
-
export const toHtml = (text: string): string =>
|
|
137
|
-
text ? renderMarkdownWithTaskLists(text) : ''
|
|
138
|
-
|
|
139
|
-
/** Convert HTML string to markdown. Returns empty string for falsy input. */
|
|
140
|
-
export const toMd = (html: string): string =>
|
|
141
|
-
html ? normalizeMarkdown(turndownService.turndown(html)) : ''
|
|
142
|
-
|
|
143
|
-
/** Batch-convert [key, markdown] entries to [key, html]. */
|
|
144
|
-
export const toHtmlEntries = (
|
|
145
|
-
entries: Array<[string, string]>,
|
|
146
|
-
): Array<[string, string]> =>
|
|
147
|
-
entries.map(([key, value]) => [key, toHtml(value)])
|
|
148
|
-
|
|
149
|
-
/** Batch-convert [key, html] entries to [key, markdown]. */
|
|
150
|
-
export const toMdEntries = (
|
|
151
|
-
entries: Array<[string, string]>,
|
|
152
|
-
keyTransform?: (key: string) => string,
|
|
153
|
-
): Array<[string, string]> =>
|
|
154
|
-
entries.map(([key, value]) => [
|
|
155
|
-
keyTransform ? keyTransform(key) : key,
|
|
156
|
-
toMd(value),
|
|
157
|
-
])
|
|
158
|
-
|
|
159
|
-
function normalizeCell(value: string): string {
|
|
160
|
-
return value.replace(/\|/g, '\\|').replace(/\s+/g, ' ').trim()
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function padRow(row: string[], length: number): string[] {
|
|
164
|
-
if (row.length >= length) return row
|
|
165
|
-
return [...row, ...Array.from({ length: length - row.length }, () => '')]
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function normalizeMarkdown(markdown: string): string {
|
|
169
|
-
return markdown
|
|
170
|
-
.replace(/^- {2,}/gm, '- ')
|
|
171
|
-
.replace(/^(\s*[-*])\s+\[(x| )\]\s+/gim, '$1 [$2] ')
|
|
172
|
-
.replace(/\n{3,}/g, '\n\n')
|
|
173
|
-
.trimEnd()
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function renderMarkdownWithTaskLists(markdown: string): string {
|
|
177
|
-
const normalized = markdown.replace(/\r\n?/g, '\n')
|
|
178
|
-
const lines = normalized.split('\n')
|
|
179
|
-
const parts: string[] = []
|
|
180
|
-
let buffer: string[] = []
|
|
181
|
-
let index = 0
|
|
182
|
-
|
|
183
|
-
const flushBuffer = (): void => {
|
|
184
|
-
if (buffer.length === 0) return
|
|
185
|
-
const chunk = buffer.join('\n')
|
|
186
|
-
if (chunk.trim()) {
|
|
187
|
-
parts.push(marked.parse(chunk) as string)
|
|
188
|
-
}
|
|
189
|
-
buffer = []
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
while (index < lines.length) {
|
|
193
|
-
const taskMatch = matchTaskLine(lines[index])
|
|
194
|
-
if (!taskMatch || taskMatch.indent !== 0) {
|
|
195
|
-
buffer.push(lines[index])
|
|
196
|
-
index += 1
|
|
197
|
-
continue
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
flushBuffer()
|
|
201
|
-
const rendered = renderTaskListBlock(lines, index, taskMatch.indent)
|
|
202
|
-
parts.push(rendered.html)
|
|
203
|
-
index = rendered.nextIndex
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
flushBuffer()
|
|
207
|
-
return parts.join('')
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function renderTaskListBlock(
|
|
211
|
-
lines: string[],
|
|
212
|
-
startIndex: number,
|
|
213
|
-
baseIndent: number,
|
|
214
|
-
): { html: string; nextIndex: number } {
|
|
215
|
-
const items: string[] = []
|
|
216
|
-
let index = startIndex
|
|
217
|
-
|
|
218
|
-
while (index < lines.length) {
|
|
219
|
-
while (
|
|
220
|
-
index < lines.length &&
|
|
221
|
-
lines[index].trim() === '' &&
|
|
222
|
-
matchTaskLine(lines[index + 1] ?? '')?.indent === baseIndent
|
|
223
|
-
) {
|
|
224
|
-
index += 1
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const taskMatch = matchTaskLine(lines[index] ?? '')
|
|
228
|
-
if (!taskMatch || taskMatch.indent !== baseIndent) break
|
|
229
|
-
|
|
230
|
-
const nestedLines: string[] = []
|
|
231
|
-
index += 1
|
|
232
|
-
|
|
233
|
-
while (index < lines.length) {
|
|
234
|
-
const line = lines[index]
|
|
235
|
-
const nextTask = matchTaskLine(line)
|
|
236
|
-
if (nextTask && nextTask.indent === baseIndent) break
|
|
237
|
-
|
|
238
|
-
if (line.trim() === '') {
|
|
239
|
-
nestedLines.push('')
|
|
240
|
-
index += 1
|
|
241
|
-
continue
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const indent = countIndent(line)
|
|
245
|
-
if (indent <= baseIndent) break
|
|
246
|
-
|
|
247
|
-
nestedLines.push(stripNestedIndent(line, baseIndent))
|
|
248
|
-
index += 1
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const itemBody = buildTaskItemBody(taskMatch.text, nestedLines)
|
|
252
|
-
items.push(
|
|
253
|
-
`<li data-type="taskItem" data-checked="${
|
|
254
|
-
taskMatch.checked
|
|
255
|
-
}"><label><input type="checkbox"${
|
|
256
|
-
taskMatch.checked ? ' checked="checked"' : ''
|
|
257
|
-
}><span></span></label><div>${itemBody}</div></li>`,
|
|
258
|
-
)
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
return {
|
|
262
|
-
html: `<ul data-type="taskList">${items.join('')}</ul>`,
|
|
263
|
-
nextIndex: index,
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function buildTaskItemBody(mainText: string, nestedLines: string[]): string {
|
|
268
|
-
const nestedMarkdown = nestedLines.join('\n').trim()
|
|
269
|
-
const tableCandidate = [mainText, nestedMarkdown]
|
|
270
|
-
.filter(Boolean)
|
|
271
|
-
.join('\n')
|
|
272
|
-
.trim()
|
|
273
|
-
|
|
274
|
-
if (looksLikeMarkdownTable(tableCandidate)) {
|
|
275
|
-
return (marked.parse(tableCandidate) as string).trim()
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
const body: string[] = []
|
|
279
|
-
if (mainText.trim()) {
|
|
280
|
-
body.push(`<p>${marked.parseInline(mainText.trim()) as string}</p>`)
|
|
281
|
-
}
|
|
282
|
-
if (nestedMarkdown) {
|
|
283
|
-
body.push(renderMarkdownWithTaskLists(nestedMarkdown).trim())
|
|
284
|
-
}
|
|
285
|
-
if (body.length === 0) {
|
|
286
|
-
body.push('<p></p>')
|
|
287
|
-
}
|
|
288
|
-
return body.join('')
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
function looksLikeMarkdownTable(markdown: string): boolean {
|
|
292
|
-
const lines = markdown
|
|
293
|
-
.split('\n')
|
|
294
|
-
.map(line => line.trim())
|
|
295
|
-
.filter(Boolean)
|
|
296
|
-
if (lines.length < 2) return false
|
|
297
|
-
return (
|
|
298
|
-
/^\|.*\|$/.test(lines[0]) &&
|
|
299
|
-
/^\|?(?:\s*:?-+:?\s*\|)+\s*:?-+:?\s*\|?$/.test(lines[1])
|
|
300
|
-
)
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
function matchTaskLine(
|
|
304
|
-
line: string,
|
|
305
|
-
): { indent: number; checked: boolean; text: string } | null {
|
|
306
|
-
const match = line.match(/^(\s*)[-+*]\s+\[([ xX])\]\s*(.*)$/)
|
|
307
|
-
if (!match) return null
|
|
308
|
-
return {
|
|
309
|
-
indent: match[1].length,
|
|
310
|
-
checked: match[2].toLowerCase() === 'x',
|
|
311
|
-
text: match[3] ?? '',
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
function countIndent(line: string): number {
|
|
316
|
-
const match = line.match(/^(\s*)/)
|
|
317
|
-
return match?.[1].length ?? 0
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
function stripNestedIndent(line: string, baseIndent: number): string {
|
|
321
|
-
const preferred = ' '.repeat(baseIndent + 4)
|
|
322
|
-
if (line.startsWith(preferred)) return line.slice(preferred.length)
|
|
323
|
-
return line.slice(Math.min(line.length, baseIndent + 2))
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function indentMarkdown(markdown: string): string {
|
|
327
|
-
return markdown
|
|
328
|
-
.split('\n')
|
|
329
|
-
.map(line => (line.trim() ? ` ${line}` : ''))
|
|
330
|
-
.join('\n')
|
|
331
|
-
}
|
|
1
|
+
import { marked } from 'marked'
|
|
2
|
+
import TurndownService from 'turndown'
|
|
3
|
+
|
|
4
|
+
const turndownService = new TurndownService({
|
|
5
|
+
headingStyle: 'atx',
|
|
6
|
+
codeBlockStyle: 'fenced',
|
|
7
|
+
emDelimiter: '*',
|
|
8
|
+
strongDelimiter: '**',
|
|
9
|
+
bulletListMarker: '-',
|
|
10
|
+
hr: '---',
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
// Clean up whitespace in paragraphs and divs
|
|
14
|
+
turndownService.addRule('cleanWhitespace', {
|
|
15
|
+
filter: (node): boolean => node.nodeName === 'P' || node.nodeName === 'DIV',
|
|
16
|
+
replacement: (content: string): string => `${content.trim()}\n\n`,
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
turndownService.addRule('taskCheckbox', {
|
|
20
|
+
filter: (node): boolean =>
|
|
21
|
+
node.nodeName === 'INPUT' && (node as HTMLInputElement).type === 'checkbox',
|
|
22
|
+
replacement: (_content: string, node): string =>
|
|
23
|
+
(node as HTMLInputElement).checked ? '[x] ' : '[ ] ',
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
turndownService.addRule('taskItem', {
|
|
27
|
+
filter: (node): boolean =>
|
|
28
|
+
node.nodeName === 'LI' &&
|
|
29
|
+
(node as HTMLElement).dataset?.type === 'taskItem',
|
|
30
|
+
replacement: (content: string, node): string => {
|
|
31
|
+
const element = node as HTMLElement
|
|
32
|
+
const checked =
|
|
33
|
+
element.getAttribute('data-checked') === 'true' ||
|
|
34
|
+
(
|
|
35
|
+
element.querySelector(
|
|
36
|
+
'input[type="checkbox"]',
|
|
37
|
+
) as HTMLInputElement | null
|
|
38
|
+
)?.checked === true
|
|
39
|
+
const prefix = checked ? '- [x] ' : '- [ ] '
|
|
40
|
+
const body = content.replace(/^\s*\[(x| )\]\s*/i, '').trim()
|
|
41
|
+
if (!body) return `${prefix}\n`
|
|
42
|
+
|
|
43
|
+
if (looksLikeMarkdownTable(body)) {
|
|
44
|
+
return `${prefix}\n${indentMarkdown(body)}\n`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const [firstBlock, ...restBlocks] = body.split(/\n{2,}/)
|
|
48
|
+
const head = `${prefix}${firstBlock.trim()}`
|
|
49
|
+
if (restBlocks.length === 0) return `${head}\n`
|
|
50
|
+
return `${head}\n\n${indentMarkdown(restBlocks.join('\n\n').trim())}\n`
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
turndownService.addRule('table', {
|
|
55
|
+
filter: (node): boolean => node.nodeName === 'TABLE',
|
|
56
|
+
replacement: (_content: string, node): string => {
|
|
57
|
+
const table = node as HTMLTableElement
|
|
58
|
+
const rows = Array.from(table.querySelectorAll('tr')).map(row =>
|
|
59
|
+
Array.from(row.children).map(cell =>
|
|
60
|
+
normalizeCell(cell.textContent ?? ''),
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
if (rows.length === 0) return ''
|
|
64
|
+
|
|
65
|
+
const header = rows[0]
|
|
66
|
+
const body = rows.slice(1)
|
|
67
|
+
const separator = header.map(() => '---')
|
|
68
|
+
const lines = [
|
|
69
|
+
`| ${header.join(' | ')} |`,
|
|
70
|
+
`| ${separator.join(' | ')} |`,
|
|
71
|
+
...body.map(row => `| ${padRow(row, header.length).join(' | ')} |`),
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
return `\n\n${lines.join('\n')}\n\n`
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// Index markers (#172): <a class="ix" data-term="X">term</a> serializes
|
|
79
|
+
// back as raw HTML so the round-trip is lossless. CommonMark passes
|
|
80
|
+
// inline HTML through unchanged.
|
|
81
|
+
turndownService.keep(
|
|
82
|
+
node =>
|
|
83
|
+
node.nodeName === 'A' &&
|
|
84
|
+
(node as HTMLElement).classList?.contains('ix') === true,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
turndownService.keep(
|
|
88
|
+
node =>
|
|
89
|
+
node.nodeName === 'SPAN' &&
|
|
90
|
+
(node as HTMLElement).hasAttribute?.('data-comment-id') === true,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
turndownService.keep(node => node.nodeName === 'SUB' || node.nodeName === 'SUP')
|
|
94
|
+
|
|
95
|
+
turndownService.addRule('inlineMath', {
|
|
96
|
+
filter: (node): boolean =>
|
|
97
|
+
node.nodeName === 'SPAN' &&
|
|
98
|
+
(node as HTMLElement).dataset?.type === 'inline-math',
|
|
99
|
+
replacement: (_content: string, node): string => {
|
|
100
|
+
const latex = (node as HTMLElement).getAttribute('data-latex') ?? ''
|
|
101
|
+
return latex ? `$${latex}$` : ''
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
turndownService.addRule('blockMath', {
|
|
106
|
+
filter: (node): boolean =>
|
|
107
|
+
node.nodeName === 'DIV' &&
|
|
108
|
+
(node as HTMLElement).dataset?.type === 'block-math',
|
|
109
|
+
replacement: (_content: string, node): string => {
|
|
110
|
+
const latex = (node as HTMLElement).getAttribute('data-latex') ?? ''
|
|
111
|
+
return latex ? `\n\n$$\n${latex}\n$$\n\n` : ''
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
// <figure><img/><figcaption>caption</figcaption></figure> → 
|
|
116
|
+
// The render pipeline already wraps caption-bearing markdown images in
|
|
117
|
+
// a real <figure>, so this lossy serialization round-trips cleanly for
|
|
118
|
+
// PDF output. The alt attribute is used as the markdown alt text only
|
|
119
|
+
// when the figure has no caption.
|
|
120
|
+
turndownService.addRule('figure', {
|
|
121
|
+
filter: (node): boolean => node.nodeName === 'FIGURE',
|
|
122
|
+
replacement: (_content: string, node): string => {
|
|
123
|
+
const el = node as HTMLElement
|
|
124
|
+
const img = el.querySelector('img')
|
|
125
|
+
const captionEl = el.querySelector('figcaption')
|
|
126
|
+
if (!img) return ''
|
|
127
|
+
const src = img.getAttribute('src') ?? ''
|
|
128
|
+
const alt = img.getAttribute('alt') ?? ''
|
|
129
|
+
const caption = (captionEl?.textContent ?? '').trim()
|
|
130
|
+
const text = caption || alt
|
|
131
|
+
return `\n\n\n\n`
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
/** Convert markdown string to HTML. Returns empty string for falsy input. */
|
|
136
|
+
export const toHtml = (text: string): string =>
|
|
137
|
+
text ? renderMarkdownWithTaskLists(text) : ''
|
|
138
|
+
|
|
139
|
+
/** Convert HTML string to markdown. Returns empty string for falsy input. */
|
|
140
|
+
export const toMd = (html: string): string =>
|
|
141
|
+
html ? normalizeMarkdown(turndownService.turndown(html)) : ''
|
|
142
|
+
|
|
143
|
+
/** Batch-convert [key, markdown] entries to [key, html]. */
|
|
144
|
+
export const toHtmlEntries = (
|
|
145
|
+
entries: Array<[string, string]>,
|
|
146
|
+
): Array<[string, string]> =>
|
|
147
|
+
entries.map(([key, value]) => [key, toHtml(value)])
|
|
148
|
+
|
|
149
|
+
/** Batch-convert [key, html] entries to [key, markdown]. */
|
|
150
|
+
export const toMdEntries = (
|
|
151
|
+
entries: Array<[string, string]>,
|
|
152
|
+
keyTransform?: (key: string) => string,
|
|
153
|
+
): Array<[string, string]> =>
|
|
154
|
+
entries.map(([key, value]) => [
|
|
155
|
+
keyTransform ? keyTransform(key) : key,
|
|
156
|
+
toMd(value),
|
|
157
|
+
])
|
|
158
|
+
|
|
159
|
+
function normalizeCell(value: string): string {
|
|
160
|
+
return value.replace(/\|/g, '\\|').replace(/\s+/g, ' ').trim()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function padRow(row: string[], length: number): string[] {
|
|
164
|
+
if (row.length >= length) return row
|
|
165
|
+
return [...row, ...Array.from({ length: length - row.length }, () => '')]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function normalizeMarkdown(markdown: string): string {
|
|
169
|
+
return markdown
|
|
170
|
+
.replace(/^- {2,}/gm, '- ')
|
|
171
|
+
.replace(/^(\s*[-*])\s+\[(x| )\]\s+/gim, '$1 [$2] ')
|
|
172
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
173
|
+
.trimEnd()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function renderMarkdownWithTaskLists(markdown: string): string {
|
|
177
|
+
const normalized = markdown.replace(/\r\n?/g, '\n')
|
|
178
|
+
const lines = normalized.split('\n')
|
|
179
|
+
const parts: string[] = []
|
|
180
|
+
let buffer: string[] = []
|
|
181
|
+
let index = 0
|
|
182
|
+
|
|
183
|
+
const flushBuffer = (): void => {
|
|
184
|
+
if (buffer.length === 0) return
|
|
185
|
+
const chunk = buffer.join('\n')
|
|
186
|
+
if (chunk.trim()) {
|
|
187
|
+
parts.push(marked.parse(chunk) as string)
|
|
188
|
+
}
|
|
189
|
+
buffer = []
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
while (index < lines.length) {
|
|
193
|
+
const taskMatch = matchTaskLine(lines[index])
|
|
194
|
+
if (!taskMatch || taskMatch.indent !== 0) {
|
|
195
|
+
buffer.push(lines[index])
|
|
196
|
+
index += 1
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
flushBuffer()
|
|
201
|
+
const rendered = renderTaskListBlock(lines, index, taskMatch.indent)
|
|
202
|
+
parts.push(rendered.html)
|
|
203
|
+
index = rendered.nextIndex
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
flushBuffer()
|
|
207
|
+
return parts.join('')
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function renderTaskListBlock(
|
|
211
|
+
lines: string[],
|
|
212
|
+
startIndex: number,
|
|
213
|
+
baseIndent: number,
|
|
214
|
+
): { html: string; nextIndex: number } {
|
|
215
|
+
const items: string[] = []
|
|
216
|
+
let index = startIndex
|
|
217
|
+
|
|
218
|
+
while (index < lines.length) {
|
|
219
|
+
while (
|
|
220
|
+
index < lines.length &&
|
|
221
|
+
lines[index].trim() === '' &&
|
|
222
|
+
matchTaskLine(lines[index + 1] ?? '')?.indent === baseIndent
|
|
223
|
+
) {
|
|
224
|
+
index += 1
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const taskMatch = matchTaskLine(lines[index] ?? '')
|
|
228
|
+
if (!taskMatch || taskMatch.indent !== baseIndent) break
|
|
229
|
+
|
|
230
|
+
const nestedLines: string[] = []
|
|
231
|
+
index += 1
|
|
232
|
+
|
|
233
|
+
while (index < lines.length) {
|
|
234
|
+
const line = lines[index]
|
|
235
|
+
const nextTask = matchTaskLine(line)
|
|
236
|
+
if (nextTask && nextTask.indent === baseIndent) break
|
|
237
|
+
|
|
238
|
+
if (line.trim() === '') {
|
|
239
|
+
nestedLines.push('')
|
|
240
|
+
index += 1
|
|
241
|
+
continue
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const indent = countIndent(line)
|
|
245
|
+
if (indent <= baseIndent) break
|
|
246
|
+
|
|
247
|
+
nestedLines.push(stripNestedIndent(line, baseIndent))
|
|
248
|
+
index += 1
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const itemBody = buildTaskItemBody(taskMatch.text, nestedLines)
|
|
252
|
+
items.push(
|
|
253
|
+
`<li data-type="taskItem" data-checked="${
|
|
254
|
+
taskMatch.checked
|
|
255
|
+
}"><label><input type="checkbox"${
|
|
256
|
+
taskMatch.checked ? ' checked="checked"' : ''
|
|
257
|
+
}><span></span></label><div>${itemBody}</div></li>`,
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
html: `<ul data-type="taskList">${items.join('')}</ul>`,
|
|
263
|
+
nextIndex: index,
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function buildTaskItemBody(mainText: string, nestedLines: string[]): string {
|
|
268
|
+
const nestedMarkdown = nestedLines.join('\n').trim()
|
|
269
|
+
const tableCandidate = [mainText, nestedMarkdown]
|
|
270
|
+
.filter(Boolean)
|
|
271
|
+
.join('\n')
|
|
272
|
+
.trim()
|
|
273
|
+
|
|
274
|
+
if (looksLikeMarkdownTable(tableCandidate)) {
|
|
275
|
+
return (marked.parse(tableCandidate) as string).trim()
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const body: string[] = []
|
|
279
|
+
if (mainText.trim()) {
|
|
280
|
+
body.push(`<p>${marked.parseInline(mainText.trim()) as string}</p>`)
|
|
281
|
+
}
|
|
282
|
+
if (nestedMarkdown) {
|
|
283
|
+
body.push(renderMarkdownWithTaskLists(nestedMarkdown).trim())
|
|
284
|
+
}
|
|
285
|
+
if (body.length === 0) {
|
|
286
|
+
body.push('<p></p>')
|
|
287
|
+
}
|
|
288
|
+
return body.join('')
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function looksLikeMarkdownTable(markdown: string): boolean {
|
|
292
|
+
const lines = markdown
|
|
293
|
+
.split('\n')
|
|
294
|
+
.map(line => line.trim())
|
|
295
|
+
.filter(Boolean)
|
|
296
|
+
if (lines.length < 2) return false
|
|
297
|
+
return (
|
|
298
|
+
/^\|.*\|$/.test(lines[0]) &&
|
|
299
|
+
/^\|?(?:\s*:?-+:?\s*\|)+\s*:?-+:?\s*\|?$/.test(lines[1])
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function matchTaskLine(
|
|
304
|
+
line: string,
|
|
305
|
+
): { indent: number; checked: boolean; text: string } | null {
|
|
306
|
+
const match = line.match(/^(\s*)[-+*]\s+\[([ xX])\]\s*(.*)$/)
|
|
307
|
+
if (!match) return null
|
|
308
|
+
return {
|
|
309
|
+
indent: match[1].length,
|
|
310
|
+
checked: match[2].toLowerCase() === 'x',
|
|
311
|
+
text: match[3] ?? '',
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function countIndent(line: string): number {
|
|
316
|
+
const match = line.match(/^(\s*)/)
|
|
317
|
+
return match?.[1].length ?? 0
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function stripNestedIndent(line: string, baseIndent: number): string {
|
|
321
|
+
const preferred = ' '.repeat(baseIndent + 4)
|
|
322
|
+
if (line.startsWith(preferred)) return line.slice(preferred.length)
|
|
323
|
+
return line.slice(Math.min(line.length, baseIndent + 2))
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function indentMarkdown(markdown: string): string {
|
|
327
|
+
return markdown
|
|
328
|
+
.split('\n')
|
|
329
|
+
.map(line => (line.trim() ? ` ${line}` : ''))
|
|
330
|
+
.join('\n')
|
|
331
|
+
}
|