decant-core 1.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/LICENSE +661 -0
- package/README.md +116 -0
- package/ai/base.js +26 -0
- package/ai/chatgpt.js +973 -0
- package/ai/chatgpt_helper.js +137 -0
- package/ai/chatgpt_scroll_collector.js +193 -0
- package/ai/claude.js +410 -0
- package/ai/claude_react_reader.js +59 -0
- package/ai/copilot.js +413 -0
- package/ai/deepseek.js +155 -0
- package/ai/gemini.js +856 -0
- package/ai/gemini_cloud_assist.js +91 -0
- package/ai/google_ai_studio.js +116 -0
- package/ai/google_search_ai.js +114 -0
- package/ai/index.js +34 -0
- package/ai/lumo.js +101 -0
- package/ai/meta.js +110 -0
- package/ai/mistral.js +57 -0
- package/ai/notebooklm.js +140 -0
- package/ai/perplexity.js +85 -0
- package/ai/qwen.js +101 -0
- package/ai/z_ai.js +101 -0
- package/detection/detect-platform.js +100 -0
- package/detection/domains.js +45 -0
- package/lib/turndown.js +803 -0
- package/package.json +56 -0
- package/utils/html-to-markdown.js +374 -0
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "decant-core",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Shared AI chat platform parsers and detection for Covai browser extensions.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "ai/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./ai/index.js",
|
|
9
|
+
"./ai/*": "./ai/*.js",
|
|
10
|
+
"./detection/*": "./detection/*.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"ai",
|
|
14
|
+
"detection",
|
|
15
|
+
"lib",
|
|
16
|
+
"utils",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/Covai-Labs/decant-core.git"
|
|
23
|
+
},
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/Covai-Labs/decant-core/issues"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/Covai-Labs/decant-core#readme",
|
|
28
|
+
"scripts": {
|
|
29
|
+
"lint": "eslint .",
|
|
30
|
+
"format": "prettier --write .",
|
|
31
|
+
"format:check": "prettier --check ."
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"ai",
|
|
35
|
+
"chat",
|
|
36
|
+
"parser",
|
|
37
|
+
"chatgpt",
|
|
38
|
+
"claude",
|
|
39
|
+
"gemini",
|
|
40
|
+
"deepseek",
|
|
41
|
+
"copilot",
|
|
42
|
+
"browser-extension"
|
|
43
|
+
],
|
|
44
|
+
"author": "Rat-S",
|
|
45
|
+
"license": "AGPL-3.0-only",
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"turndown": "^7.2.4"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@eslint/js": "^10.0.1",
|
|
51
|
+
"eslint": "^10.8.0",
|
|
52
|
+
"globals": "^17.9.0",
|
|
53
|
+
"prettier": "^3.9.6"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML to Markdown Converter Utility
|
|
3
|
+
* Uses Turndown.js to convert HTML elements to markdown format
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Note: Turndown.js will be loaded separately via manifest.json
|
|
7
|
+
// This utility provides a wrapper around it for consistent usage across parsers
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Convert an HTML element to markdown
|
|
11
|
+
* @param {HTMLElement|string} htmlContent - HTML element or HTML string to convert
|
|
12
|
+
* @param {Object} options - Optional configuration for the conversion
|
|
13
|
+
* @returns {string} Markdown formatted text
|
|
14
|
+
*/
|
|
15
|
+
import TurndownService from '../lib/turndown.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Convert an HTML element to markdown
|
|
19
|
+
* @param {HTMLElement|string} htmlContent - HTML element or HTML string to convert
|
|
20
|
+
* @param {Object} options - Optional configuration for the conversion
|
|
21
|
+
* @returns {string} Markdown formatted text
|
|
22
|
+
*/
|
|
23
|
+
export function convertToMarkdown(htmlContent, options = {}) {
|
|
24
|
+
// Configure Turndown service
|
|
25
|
+
const turndownService = new TurndownService({
|
|
26
|
+
headingStyle: 'atx', // Use # for headings (not underline style)
|
|
27
|
+
hr: '* * *', // Horizontal rules
|
|
28
|
+
bulletListMarker: '*', // Use * for bullet lists
|
|
29
|
+
codeBlockStyle: 'fenced', // Use ``` for code blocks
|
|
30
|
+
fence: '```', // Code block fence characters
|
|
31
|
+
emDelimiter: '*', // Use * for emphasis
|
|
32
|
+
strongDelimiter: '**', // Use ** for strong
|
|
33
|
+
linkStyle: 'inlined', // [text](url) format
|
|
34
|
+
linkReferenceStyle: 'full', // Full reference style for links
|
|
35
|
+
...options,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Add custom rules for better conversion
|
|
39
|
+
|
|
40
|
+
// Preserve line breaks
|
|
41
|
+
turndownService.addRule('lineBreak', {
|
|
42
|
+
filter: ['br'],
|
|
43
|
+
replacement: () => ' \n',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Handle Qwen's Monaco editor code blocks
|
|
47
|
+
turndownService.addRule('qwenCodeBlock', {
|
|
48
|
+
filter: function (node) {
|
|
49
|
+
return node.nodeName === 'PRE' && node.classList.contains('qwen-markdown-code');
|
|
50
|
+
},
|
|
51
|
+
replacement: function (content, node) {
|
|
52
|
+
// Extract language from header
|
|
53
|
+
let language = '';
|
|
54
|
+
const headerDiv = node.querySelector('.qwen-markdown-code-header div');
|
|
55
|
+
if (headerDiv) {
|
|
56
|
+
language = headerDiv.textContent.trim();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Extract code from Monaco editor view-lines
|
|
60
|
+
const viewLines = node.querySelectorAll('.view-line');
|
|
61
|
+
const codeLines = [];
|
|
62
|
+
viewLines.forEach((line) => {
|
|
63
|
+
// Get text content, preserving structure
|
|
64
|
+
let lineText = '';
|
|
65
|
+
const spans = line.querySelectorAll('span');
|
|
66
|
+
spans.forEach((span) => {
|
|
67
|
+
// Replace with spaces and get text
|
|
68
|
+
const text = span.textContent.replace(/\u00A0/g, ' ');
|
|
69
|
+
lineText += text;
|
|
70
|
+
});
|
|
71
|
+
codeLines.push(lineText);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const code = codeLines.join('\n');
|
|
75
|
+
if (!code.trim()) return '';
|
|
76
|
+
|
|
77
|
+
return '\n```' + language + '\n' + code + '\n```\n';
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Add table conversion rule
|
|
82
|
+
turndownService.addRule('tables', {
|
|
83
|
+
filter: 'table',
|
|
84
|
+
replacement: function (content, node) {
|
|
85
|
+
// Convert HTML table to markdown table
|
|
86
|
+
const rows = Array.from(node.querySelectorAll('tr'));
|
|
87
|
+
if (rows.length === 0) return content;
|
|
88
|
+
|
|
89
|
+
// Create a separate Turndown service for cell content to avoid recursion/state issues
|
|
90
|
+
// and incorrectly stripping or double-processing content
|
|
91
|
+
const cellTurndown = new TurndownService({
|
|
92
|
+
emDelimiter: '*',
|
|
93
|
+
strongDelimiter: '**',
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Handle breaks in cells
|
|
97
|
+
// Convert BR to newlines first so Turndown sees them as breaks (or handle directly)
|
|
98
|
+
// Actually Turndown by default drops BRs or converts to newline.
|
|
99
|
+
// We want to force them to <br> string for table cells.
|
|
100
|
+
cellTurndown.addRule('lineBreak', {
|
|
101
|
+
filter: ['br'],
|
|
102
|
+
replacement: () => '<br>',
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
let markdown = '\n';
|
|
106
|
+
rows.forEach((row, rowIndex) => {
|
|
107
|
+
const cells = Array.from(row.querySelectorAll('th, td'));
|
|
108
|
+
|
|
109
|
+
// Convert each cell's HTML to markdown using the isolated service
|
|
110
|
+
const cellContents = cells.map((cell) => {
|
|
111
|
+
let cellMarkdown = cellTurndown.turndown(cell.innerHTML);
|
|
112
|
+
// Replace any actual newlines that Turndown generated (e.g. from P tags) with <br>
|
|
113
|
+
// as tables cannot have literal newlines in GFM.
|
|
114
|
+
return cellMarkdown.trim().replace(/\n/g, '<br>');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Add row
|
|
118
|
+
markdown += '| ' + cellContents.join(' | ') + ' |\n';
|
|
119
|
+
|
|
120
|
+
// Add separator after header row
|
|
121
|
+
if (rowIndex === 0) {
|
|
122
|
+
markdown += '| ' + cells.map(() => '---').join(' | ') + ' |\n';
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return markdown + '\n';
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Convert the HTML
|
|
131
|
+
let html;
|
|
132
|
+
if (typeof htmlContent === 'string') {
|
|
133
|
+
html = htmlContent;
|
|
134
|
+
} else if (
|
|
135
|
+
htmlContent &&
|
|
136
|
+
(htmlContent instanceof HTMLElement ||
|
|
137
|
+
htmlContent.nodeType === 1 ||
|
|
138
|
+
htmlContent.nodeType === 9 ||
|
|
139
|
+
typeof htmlContent.querySelectorAll === 'function')
|
|
140
|
+
) {
|
|
141
|
+
// Clone the element to avoid modifying the original
|
|
142
|
+
const clone = htmlContent.cloneNode(true);
|
|
143
|
+
|
|
144
|
+
// Convert math equations (KaTeX and data-math) to standard markdown math blocks
|
|
145
|
+
// 1. Process block display KaTeX
|
|
146
|
+
clone.querySelectorAll('.katex-display').forEach((el) => {
|
|
147
|
+
const annotation = el.querySelector('annotation[encoding="application/x-tex"]');
|
|
148
|
+
if (annotation) {
|
|
149
|
+
const latex = annotation.textContent.trim();
|
|
150
|
+
const textNode = clone.ownerDocument.createTextNode(`\n\n$$${latex}$$\n\n`);
|
|
151
|
+
el.parentNode.replaceChild(textNode, el);
|
|
152
|
+
} else {
|
|
153
|
+
const textNode = clone.ownerDocument.createTextNode(`\n\n$$${el.textContent.trim()}$$\n\n`);
|
|
154
|
+
el.parentNode.replaceChild(textNode, el);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// 2. Process inline KaTeX
|
|
159
|
+
clone.querySelectorAll('.katex').forEach((el) => {
|
|
160
|
+
if (!el.parentNode) return;
|
|
161
|
+
const annotation = el.querySelector('annotation[encoding="application/x-tex"]');
|
|
162
|
+
if (annotation) {
|
|
163
|
+
const latex = annotation.textContent.trim();
|
|
164
|
+
const textNode = clone.ownerDocument.createTextNode(`$${latex}$`);
|
|
165
|
+
el.parentNode.replaceChild(textNode, el);
|
|
166
|
+
} else {
|
|
167
|
+
const textNode = clone.ownerDocument.createTextNode(`$${el.textContent.trim()}$`);
|
|
168
|
+
el.parentNode.replaceChild(textNode, el);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// 3. Process Gemini-style data-math attributes
|
|
173
|
+
clone.querySelectorAll('[data-math]').forEach((el) => {
|
|
174
|
+
if (!el.parentNode) return;
|
|
175
|
+
const latex = el.getAttribute('data-math');
|
|
176
|
+
const isBlock = el.classList.contains('math-block') || el.tagName === 'DIV';
|
|
177
|
+
const replacementText = isBlock ? `\n\n$$${latex}$$\n\n` : `$${latex}$`;
|
|
178
|
+
const textNode = clone.ownerDocument.createTextNode(replacementText);
|
|
179
|
+
el.parentNode.replaceChild(textNode, el);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// 4. Process Google Search SGE LaTeX images with [data-xpm-latex]
|
|
183
|
+
clone.querySelectorAll('[data-xpm-latex]').forEach((el) => {
|
|
184
|
+
const copyRoot = el.closest('[data-xpm-copy-root]');
|
|
185
|
+
if (!copyRoot) return;
|
|
186
|
+
const container = el.closest('.cPGBZb') || copyRoot;
|
|
187
|
+
if (!container.parentNode) return;
|
|
188
|
+
|
|
189
|
+
const latex = el.getAttribute('data-xpm-latex');
|
|
190
|
+
|
|
191
|
+
// Determine if it is block math
|
|
192
|
+
let isBlock = false;
|
|
193
|
+
const parent = container.parentNode;
|
|
194
|
+
if (parent) {
|
|
195
|
+
const parentText = parent.textContent.replace(container.textContent, '').trim();
|
|
196
|
+
isBlock = parentText === '';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const replacementText = isBlock ? `\n\n$$${latex}$$\n\n` : `$${latex}$`;
|
|
200
|
+
const textNode = clone.ownerDocument.createTextNode(replacementText);
|
|
201
|
+
container.parentNode.replaceChild(textNode, container);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Preprocess code blocks (pre elements) to normalize formatting and language tags
|
|
205
|
+
clone.querySelectorAll('pre').forEach((pre) => {
|
|
206
|
+
// Guard against processing detached/already-replaced pre elements
|
|
207
|
+
if (!clone.contains(pre)) return;
|
|
208
|
+
|
|
209
|
+
const innerPre = pre.querySelector('pre');
|
|
210
|
+
const codeElement = pre.querySelector('code');
|
|
211
|
+
|
|
212
|
+
if (innerPre && codeElement) {
|
|
213
|
+
// --- ChatGPT-style wrapped code block ---
|
|
214
|
+
// Find the language label from the header (usually next to an SVG icon)
|
|
215
|
+
let language = '';
|
|
216
|
+
const svg = pre.querySelector('svg');
|
|
217
|
+
if (svg && svg.parentElement) {
|
|
218
|
+
const parent = svg.parentElement;
|
|
219
|
+
svg.remove();
|
|
220
|
+
language = parent.textContent.trim().toLowerCase();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Replace all <br> elements inside the code block with actual newlines
|
|
224
|
+
codeElement.querySelectorAll('br').forEach((br) => {
|
|
225
|
+
if (br.parentNode) {
|
|
226
|
+
const newline = clone.ownerDocument.createTextNode('\n');
|
|
227
|
+
br.parentNode.replaceChild(newline, br);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const codeText = codeElement.textContent;
|
|
232
|
+
|
|
233
|
+
// Create a new clean pre and code element structure
|
|
234
|
+
const newPre = clone.ownerDocument.createElement('pre');
|
|
235
|
+
const newCode = clone.ownerDocument.createElement('code');
|
|
236
|
+
if (language) {
|
|
237
|
+
newCode.className = `language-${language}`;
|
|
238
|
+
}
|
|
239
|
+
newCode.textContent = codeText;
|
|
240
|
+
newPre.appendChild(newCode);
|
|
241
|
+
|
|
242
|
+
// Replace the outer pre with the new clean pre
|
|
243
|
+
if (pre.parentNode) {
|
|
244
|
+
pre.parentNode.replaceChild(newPre, pre);
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
// --- Standard/SGE-style code block ---
|
|
248
|
+
// Extract language label if available (e.g. from Google Search SGE containers)
|
|
249
|
+
const code = pre.querySelector('code');
|
|
250
|
+
const hasLanguage =
|
|
251
|
+
code &&
|
|
252
|
+
Array.from(code.classList).some(
|
|
253
|
+
(cls) => cls.startsWith('language-') && cls !== 'language-',
|
|
254
|
+
);
|
|
255
|
+
if (!hasLanguage) {
|
|
256
|
+
const container = pre.closest('.pHpOfb') || pre.parentElement?.parentElement;
|
|
257
|
+
if (container) {
|
|
258
|
+
const langEl =
|
|
259
|
+
container.querySelector('.vVRw1d') || container.firstElementChild?.firstElementChild;
|
|
260
|
+
if (langEl && langEl !== pre) {
|
|
261
|
+
const language = langEl.textContent.trim().toLowerCase();
|
|
262
|
+
if (code) {
|
|
263
|
+
code.className = `language-${language}`;
|
|
264
|
+
}
|
|
265
|
+
langEl.remove();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Replace all <br> elements inside the code block with actual newlines
|
|
271
|
+
const codeEl = pre.querySelector('code') || pre;
|
|
272
|
+
codeEl.querySelectorAll('br').forEach((br) => {
|
|
273
|
+
if (br.parentNode) {
|
|
274
|
+
const newline = clone.ownerDocument.createTextNode('\n');
|
|
275
|
+
br.parentNode.replaceChild(newline, br);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Remove SGE "Use code with caution" text blocks
|
|
282
|
+
clone.querySelectorAll('*').forEach((el) => {
|
|
283
|
+
if (el.textContent.trim() === 'Use code with caution.') {
|
|
284
|
+
el.remove();
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// Pre-process button-wrapped images (e.g., ChatGPT image carousels) before noise button removal
|
|
289
|
+
clone.querySelectorAll('button').forEach((button) => {
|
|
290
|
+
const img = button.querySelector('img');
|
|
291
|
+
if (!img) return;
|
|
292
|
+
|
|
293
|
+
let caption = 'Image';
|
|
294
|
+
const ariaLabel = button.getAttribute('aria-label') || '';
|
|
295
|
+
if (ariaLabel.toLowerCase().includes('open image details for')) {
|
|
296
|
+
caption = ariaLabel.replace(/^Open image details for\s*/i, '').trim();
|
|
297
|
+
} else if (img.getAttribute('alt') && !img.getAttribute('alt').startsWith('http')) {
|
|
298
|
+
caption = img.getAttribute('alt').trim();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const alt = img.getAttribute('alt') || '';
|
|
302
|
+
const src = img.getAttribute('src') || '';
|
|
303
|
+
const imageUrl = alt.startsWith('http://') || alt.startsWith('https://') ? alt : src;
|
|
304
|
+
|
|
305
|
+
if (imageUrl) {
|
|
306
|
+
const newImg = clone.ownerDocument.createElement('img');
|
|
307
|
+
newImg.setAttribute('src', imageUrl);
|
|
308
|
+
newImg.setAttribute('alt', caption);
|
|
309
|
+
button.parentNode.replaceChild(newImg, button);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Remove noise elements (buttons, icons, etc.)
|
|
314
|
+
// Note: Exclude elements inside qwen-markdown-code to preserve Monaco editor content
|
|
315
|
+
const noiseSelectors = [
|
|
316
|
+
'button:not(.qwen-markdown-code *)',
|
|
317
|
+
'.copy-button',
|
|
318
|
+
'[role="button"]:not(.qwen-markdown-code *)',
|
|
319
|
+
'.sr-only',
|
|
320
|
+
'svg:not(.qwen-markdown-code *)',
|
|
321
|
+
'.icon:not(.qwen-markdown-code *)',
|
|
322
|
+
'[aria-hidden="true"]:not(.qwen-markdown-code *)',
|
|
323
|
+
'[id^="shrproxy"]',
|
|
324
|
+
'[id^="fbproxy"]',
|
|
325
|
+
'div.HvurC',
|
|
326
|
+
'.DBd2Wb',
|
|
327
|
+
'.oLpkLe',
|
|
328
|
+
'.OUQe0e',
|
|
329
|
+
'.UrecDd',
|
|
330
|
+
'[aria-label="Share public link"]',
|
|
331
|
+
'aside.L9AUvd',
|
|
332
|
+
'aside.UL0w9b',
|
|
333
|
+
'div.qacuz',
|
|
334
|
+
];
|
|
335
|
+
|
|
336
|
+
noiseSelectors.forEach((selector) => {
|
|
337
|
+
clone.querySelectorAll(selector).forEach((el) => el.remove());
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
html = clone;
|
|
341
|
+
} else {
|
|
342
|
+
console.warn('Invalid input to convertToMarkdown:', htmlContent);
|
|
343
|
+
return '';
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
const markdown = turndownService.turndown(html);
|
|
348
|
+
return markdown.trim();
|
|
349
|
+
} catch (error) {
|
|
350
|
+
console.error('Error converting HTML to markdown:', error);
|
|
351
|
+
// Fallback to plain text
|
|
352
|
+
const parser = new DOMParser();
|
|
353
|
+
const doc = parser.parseFromString(html, 'text/html');
|
|
354
|
+
return doc.body.innerText || doc.body.textContent || '';
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Clean up markdown text by removing common artifacts
|
|
360
|
+
* @param {string} markdown - Markdown text to clean
|
|
361
|
+
* @returns {string} Cleaned markdown
|
|
362
|
+
*/
|
|
363
|
+
export function cleanMarkdown(markdown) {
|
|
364
|
+
return (
|
|
365
|
+
markdown
|
|
366
|
+
// Remove excessive blank lines (max 2 consecutive)
|
|
367
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
368
|
+
// Remove trailing whitespace from lines
|
|
369
|
+
.replace(/[ \t]+$/gm, '')
|
|
370
|
+
// Normalize horizontal rules
|
|
371
|
+
.replace(/^(-{3,}|\*{3,}|_{3,})$/gm, '* * *')
|
|
372
|
+
.trim()
|
|
373
|
+
);
|
|
374
|
+
}
|