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