markpaste 0.0.1 → 0.0.6
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/.github/workflows/publish.yml +36 -0
- package/.nojekyll +0 -0
- package/AGENTS.md +2 -1
- package/README.md +11 -3
- package/bin/markpaste +125 -0
- package/index.html +8 -4
- package/package.json +16 -8
- package/playwright.config.ts +2 -2
- package/scripts/compare-converters.js +119 -0
- package/src/app.js +153 -39
- package/src/cleaner.js +71 -16
- package/src/converter.js +1 -1
- package/src/index.js +46 -0
- package/src/pandoc.js +2 -2
- package/src/renderer.js +4 -4
- package/src/style.css +43 -0
- package/test/node/cleaner.test.js +24 -5
- package/test/node/converter.test.js +10 -7
- package/test/node/index.test.js +17 -4
- package/test/node/markpaste.test.js +72 -0
- package/test/node/pandoc.test.js +3 -3
- package/test/web/basic-load.spec.ts +6 -6
- package/test/web/cleaner.spec.ts +1 -1
- package/test/web/pasting.spec.ts +31 -4
- package/types/globals.d.ts +10 -7
- package/src/markpaste.js +0 -26
package/src/app.js
CHANGED
|
@@ -15,7 +15,7 @@ window.$ = function (query, context) {
|
|
|
15
15
|
if (result === null) {
|
|
16
16
|
throw new Error(`query ${query} not found`);
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
return /** @type {import('typed-query-selector/parser.js').ParseSelector<T, Element>} */ (result);
|
|
19
19
|
};
|
|
20
20
|
/**
|
|
21
21
|
* @template {string} T
|
|
@@ -55,13 +55,14 @@ function toggleTheme() {
|
|
|
55
55
|
localStorage.setItem('theme', newTheme);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
const {$, $$
|
|
58
|
+
const {$, $$} = window;
|
|
59
59
|
|
|
60
60
|
const inputArea = $('div#inputArea');
|
|
61
61
|
const htmlCode = $('code#htmlCode');
|
|
62
62
|
const copyBtn = $('button#copyBtn');
|
|
63
63
|
const themeToggle = $('button#themeToggle');
|
|
64
64
|
const cleanHtmlToggle = $('input#cleanHtmlToggle');
|
|
65
|
+
const loadingOverlay = $('div#loadingOverlay');
|
|
65
66
|
|
|
66
67
|
// View Toggle
|
|
67
68
|
const viewMarkdownBtn = $('button#viewMarkdownBtn');
|
|
@@ -97,7 +98,6 @@ const convertersPromise = (async () => {
|
|
|
97
98
|
let currentView = 'markdown'; // 'markdown' or 'rendered'
|
|
98
99
|
|
|
99
100
|
async function init() {
|
|
100
|
-
|
|
101
101
|
setupEventListeners();
|
|
102
102
|
|
|
103
103
|
loadTheme();
|
|
@@ -108,7 +108,7 @@ async function init() {
|
|
|
108
108
|
// Initial process if there's content (e.g. from reload, though usually empty)
|
|
109
109
|
if (inputArea.innerHTML) {
|
|
110
110
|
lastProcessedContent = inputArea.innerHTML;
|
|
111
|
-
processContent(lastProcessedContent);
|
|
111
|
+
await processContent(lastProcessedContent);
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
|
|
@@ -133,23 +133,24 @@ async function startIdleDetector() {
|
|
|
133
133
|
if (userState === 'idle') {
|
|
134
134
|
// Unload pandoc if it exists
|
|
135
135
|
if (converters.pandoc) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
136
|
+
console.log('User is idle. Unloading pandoc module to free memory.');
|
|
137
|
+
if (converters.pandoc.dispose) {
|
|
138
|
+
converters.pandoc.dispose();
|
|
139
|
+
}
|
|
140
|
+
delete converters.pandoc;
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
143
|
});
|
|
144
144
|
|
|
145
145
|
// 10 minutes = 600,000 ms
|
|
146
|
-
idleDetector
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
146
|
+
idleDetector
|
|
147
|
+
.start({
|
|
148
|
+
threshold: 600000,
|
|
149
|
+
signal,
|
|
150
|
+
})
|
|
151
|
+
.catch(err => {
|
|
152
|
+
console.warn('Idle detection start failed:', err);
|
|
153
|
+
});
|
|
153
154
|
} catch (err) {
|
|
154
155
|
console.warn('Idle detection setup failed:', err);
|
|
155
156
|
}
|
|
@@ -159,18 +160,40 @@ async function startIdleDetector() {
|
|
|
159
160
|
function setupEventListeners() {
|
|
160
161
|
inputArea.on('paste', handlePaste);
|
|
161
162
|
|
|
162
|
-
inputArea.on('input', () => {
|
|
163
|
+
inputArea.on('input', async () => {
|
|
163
164
|
lastProcessedContent = inputArea.innerHTML;
|
|
164
|
-
|
|
165
|
+
if (lastProcessedContent.length > 10000) {
|
|
166
|
+
showLoading();
|
|
167
|
+
setTimeout(async () => {
|
|
168
|
+
try {
|
|
169
|
+
await processContent(lastProcessedContent);
|
|
170
|
+
} finally {
|
|
171
|
+
hideLoading();
|
|
172
|
+
}
|
|
173
|
+
}, 10);
|
|
174
|
+
} else {
|
|
175
|
+
await processContent(lastProcessedContent);
|
|
176
|
+
}
|
|
165
177
|
});
|
|
166
178
|
|
|
167
179
|
copyBtn.on('click', copyToClipboard);
|
|
168
180
|
|
|
169
181
|
themeToggle.on('click', toggleTheme);
|
|
170
182
|
|
|
171
|
-
cleanHtmlToggle.on('change', () => {
|
|
183
|
+
cleanHtmlToggle.on('change', async () => {
|
|
172
184
|
if (lastProcessedContent) {
|
|
173
|
-
|
|
185
|
+
if (lastProcessedContent.length > 5000) {
|
|
186
|
+
showLoading();
|
|
187
|
+
setTimeout(async () => {
|
|
188
|
+
try {
|
|
189
|
+
await processContent(lastProcessedContent);
|
|
190
|
+
} finally {
|
|
191
|
+
hideLoading();
|
|
192
|
+
}
|
|
193
|
+
}, 10);
|
|
194
|
+
} else {
|
|
195
|
+
await processContent(lastProcessedContent);
|
|
196
|
+
}
|
|
174
197
|
}
|
|
175
198
|
});
|
|
176
199
|
|
|
@@ -225,6 +248,14 @@ function handleSelectAll(e) {
|
|
|
225
248
|
}
|
|
226
249
|
}
|
|
227
250
|
|
|
251
|
+
function showLoading() {
|
|
252
|
+
loadingOverlay.classList.remove('hidden');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function hideLoading() {
|
|
256
|
+
loadingOverlay.classList.add('hidden');
|
|
257
|
+
}
|
|
258
|
+
|
|
228
259
|
async function handlePaste(e) {
|
|
229
260
|
e.preventDefault();
|
|
230
261
|
|
|
@@ -232,14 +263,25 @@ async function handlePaste(e) {
|
|
|
232
263
|
const pastedHtml = clipboardData.getData('text/html');
|
|
233
264
|
const pastedText = clipboardData.getData('text/plain');
|
|
234
265
|
|
|
266
|
+
showLoading();
|
|
267
|
+
|
|
235
268
|
await convertersPromise;
|
|
236
269
|
|
|
237
|
-
const
|
|
270
|
+
const isMarkdown = isProbablyMarkdown(pastedText, !!pastedHtml);
|
|
271
|
+
const content = isMarkdown ? pastedText : pastedHtml || pastedText;
|
|
238
272
|
lastProcessedContent = content;
|
|
239
|
-
processContent(content);
|
|
240
273
|
|
|
241
|
-
//
|
|
242
|
-
|
|
274
|
+
// Use setTimeout to allow UI to update before blocking the thread with conversion
|
|
275
|
+
setTimeout(async () => {
|
|
276
|
+
try {
|
|
277
|
+
await processContent(content, isMarkdown);
|
|
278
|
+
} finally {
|
|
279
|
+
hideLoading();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Reset scroll position for all pre elements
|
|
283
|
+
$$('pre').forEach(pre => (pre.scrollTop = 0));
|
|
284
|
+
}, 10);
|
|
243
285
|
|
|
244
286
|
inputArea.innerHTML = '';
|
|
245
287
|
inputArea.setAttribute('placeholder', 'Pasted! Ready for more...');
|
|
@@ -248,34 +290,106 @@ async function handlePaste(e) {
|
|
|
248
290
|
}, 2000);
|
|
249
291
|
}
|
|
250
292
|
|
|
251
|
-
function
|
|
252
|
-
|
|
253
|
-
const
|
|
293
|
+
function isProbablyMarkdown(text, hasHtml) {
|
|
294
|
+
if (hasHtml) return false;
|
|
295
|
+
const trimmed = text.trim();
|
|
296
|
+
if (trimmed.startsWith('<')) return false;
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function processContent(content, isMarkdown = null) {
|
|
301
|
+
let htmlToShow;
|
|
302
|
+
let markdownResults;
|
|
303
|
+
|
|
304
|
+
if (isMarkdown === null) {
|
|
305
|
+
isMarkdown = isProbablyMarkdown(content, false);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (isMarkdown) {
|
|
309
|
+
// If it's markdown, the "results" are just the content itself.
|
|
310
|
+
markdownResults = {
|
|
311
|
+
turndown: content,
|
|
312
|
+
pandoc: content,
|
|
313
|
+
};
|
|
314
|
+
// For the HTML preview, we render the markdown.
|
|
315
|
+
const tempDiv = document.createElement('div');
|
|
316
|
+
await renderMarkdown(content, tempDiv);
|
|
317
|
+
htmlToShow = tempDiv.innerHTML;
|
|
318
|
+
} else {
|
|
319
|
+
const shouldClean = cleanHtmlToggle.checked;
|
|
320
|
+
const contentToConvert = shouldClean ? cleanHTML(content) : removeStyleAttributes(content);
|
|
321
|
+
htmlToShow = contentToConvert;
|
|
322
|
+
markdownResults = runConverters(contentToConvert);
|
|
323
|
+
}
|
|
254
324
|
|
|
255
325
|
// Update HTML Preview
|
|
256
|
-
htmlCode.textContent = formatHTML(
|
|
326
|
+
htmlCode.textContent = formatHTML(htmlToShow);
|
|
257
327
|
if (window.Prism) {
|
|
258
328
|
window.Prism.highlightElement(htmlCode);
|
|
259
329
|
}
|
|
260
330
|
|
|
261
|
-
//
|
|
331
|
+
// Update UI with results
|
|
332
|
+
for (const [name, markdown] of Object.entries(markdownResults)) {
|
|
333
|
+
if (outputs[name]) {
|
|
334
|
+
outputs[name].code.textContent = markdown;
|
|
335
|
+
if (window.Prism) {
|
|
336
|
+
window.Prism.highlightElement(outputs[name].code);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Fire and forget the diff check
|
|
342
|
+
if (!isMarkdown) {
|
|
343
|
+
checkDiffs(markdownResults);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (currentView === 'rendered') {
|
|
347
|
+
updateRenderedPreviews();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function runConverters(htmlContent) {
|
|
352
|
+
const results = {};
|
|
262
353
|
for (const [name, converter] of Object.entries(converters)) {
|
|
263
354
|
if (converter) {
|
|
264
355
|
try {
|
|
265
|
-
|
|
266
|
-
outputs[name].code.textContent = markdown;
|
|
267
|
-
if (window.Prism) {
|
|
268
|
-
window.Prism.highlightElement(outputs[name].code);
|
|
269
|
-
}
|
|
356
|
+
results[name] = converter.convert(htmlContent);
|
|
270
357
|
} catch (err) {
|
|
271
358
|
console.error(`Converter ${name} failed:`, err);
|
|
272
|
-
|
|
359
|
+
results[name] = `Error converting with ${name}: ${err.message}`;
|
|
273
360
|
}
|
|
274
361
|
}
|
|
275
362
|
}
|
|
363
|
+
return results;
|
|
364
|
+
}
|
|
276
365
|
|
|
277
|
-
|
|
278
|
-
|
|
366
|
+
async function checkDiffs(results) {
|
|
367
|
+
// We need both to be present and not error messages
|
|
368
|
+
if (!results.turndown || !results.pandoc) return;
|
|
369
|
+
if (results.turndown.startsWith('Error converting') || results.pandoc.startsWith('Error converting')) return;
|
|
370
|
+
|
|
371
|
+
const tDiv = document.createElement('div');
|
|
372
|
+
const pDiv = document.createElement('div');
|
|
373
|
+
|
|
374
|
+
await renderMarkdown(results.turndown, tDiv);
|
|
375
|
+
await renderMarkdown(results.pandoc, pDiv);
|
|
376
|
+
|
|
377
|
+
const turndownHtml = tDiv.innerHTML;
|
|
378
|
+
const pandocHtml = pDiv.innerHTML;
|
|
379
|
+
tDiv.innerHTML = pDiv.innerHTML = '';
|
|
380
|
+
|
|
381
|
+
if (turndownHtml !== pandocHtml) {
|
|
382
|
+
let firstDiff = 0;
|
|
383
|
+
const maxLength = Math.max(turndownHtml.length, pandocHtml.length);
|
|
384
|
+
while (firstDiff < maxLength && turndownHtml[firstDiff] === pandocHtml[firstDiff]) {
|
|
385
|
+
firstDiff++;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
console.group('Converter Diff Discrepancy');
|
|
389
|
+
console.log(`First difference at index ${firstDiff}:`);
|
|
390
|
+
console.log(` Turndown: "...${turndownHtml.substring(firstDiff, firstDiff + 40)}..."`);
|
|
391
|
+
console.log(` Pandoc: "...${pandocHtml.substring(firstDiff, firstDiff + 40)}..."`);
|
|
392
|
+
console.groupEnd();
|
|
279
393
|
}
|
|
280
394
|
}
|
|
281
395
|
|
|
@@ -341,7 +455,7 @@ async function copyToClipboard() {
|
|
|
341
455
|
|
|
342
456
|
try {
|
|
343
457
|
const items = {
|
|
344
|
-
'text/plain': new Blob([textToCopy], {type: 'text/plain'})
|
|
458
|
+
'text/plain': new Blob([textToCopy], {type: 'text/plain'}),
|
|
345
459
|
};
|
|
346
460
|
if (htmlToCopy) {
|
|
347
461
|
items['text/html'] = new Blob([htmlToCopy], {type: 'text/html'});
|
|
@@ -361,7 +475,7 @@ async function copyToClipboard() {
|
|
|
361
475
|
console.error('Failed to copy:', err);
|
|
362
476
|
copyBtn.textContent = 'Copy failed';
|
|
363
477
|
setTimeout(() => {
|
|
364
|
-
|
|
478
|
+
copyBtn.innerHTML = originalText;
|
|
365
479
|
}, 2000);
|
|
366
480
|
}
|
|
367
481
|
}
|
package/src/cleaner.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
let parseHTMLGlobal, documentGlobal, NodeGlobal;
|
|
8
8
|
|
|
9
9
|
if (typeof window !== 'undefined') {
|
|
10
|
-
parseHTMLGlobal =
|
|
10
|
+
parseHTMLGlobal = html => {
|
|
11
11
|
const parser = new DOMParser();
|
|
12
12
|
return parser.parseFromString(html, 'text/html');
|
|
13
13
|
};
|
|
@@ -15,8 +15,8 @@ if (typeof window !== 'undefined') {
|
|
|
15
15
|
NodeGlobal = window.Node;
|
|
16
16
|
} else {
|
|
17
17
|
// We are in Node.js
|
|
18
|
-
const {
|
|
19
|
-
parseHTMLGlobal =
|
|
18
|
+
const {parseHTML} = await import('linkedom');
|
|
19
|
+
parseHTMLGlobal = html => {
|
|
20
20
|
const fullHtml = `<!DOCTYPE html><html><body>${html}</body></html>`;
|
|
21
21
|
return parseHTML(fullHtml).document;
|
|
22
22
|
};
|
|
@@ -26,8 +26,35 @@ if (typeof window !== 'undefined') {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
const ALLOWED_TAGS = [
|
|
29
|
-
'P',
|
|
30
|
-
'
|
|
29
|
+
'P',
|
|
30
|
+
'STRONG',
|
|
31
|
+
'B',
|
|
32
|
+
'EM',
|
|
33
|
+
'I',
|
|
34
|
+
'BLOCKQUOTE',
|
|
35
|
+
'CODE',
|
|
36
|
+
'PRE',
|
|
37
|
+
'A',
|
|
38
|
+
'H1',
|
|
39
|
+
'H2',
|
|
40
|
+
'H3',
|
|
41
|
+
'H4',
|
|
42
|
+
'H5',
|
|
43
|
+
'H6',
|
|
44
|
+
'UL',
|
|
45
|
+
'OL',
|
|
46
|
+
'LI',
|
|
47
|
+
'DL',
|
|
48
|
+
'DT',
|
|
49
|
+
'DD',
|
|
50
|
+
'BR',
|
|
51
|
+
'HR',
|
|
52
|
+
'TABLE',
|
|
53
|
+
'THEAD',
|
|
54
|
+
'TBODY',
|
|
55
|
+
'TR',
|
|
56
|
+
'TH',
|
|
57
|
+
'TD',
|
|
31
58
|
];
|
|
32
59
|
|
|
33
60
|
const ALLOWED_ATTRIBUTES = {
|
|
@@ -72,27 +99,45 @@ function processNode(sourceNode, targetParent) {
|
|
|
72
99
|
if (sourceNode.classList && sourceNode.classList.contains('mdn-copy-button')) {
|
|
73
100
|
return;
|
|
74
101
|
}
|
|
75
|
-
|
|
102
|
+
|
|
76
103
|
const href = sourceNode.getAttribute('href');
|
|
77
|
-
if (
|
|
78
|
-
tagName === 'A' &&
|
|
79
|
-
href && href.startsWith('https://developer.mozilla.org/en-US/play')
|
|
80
|
-
) {
|
|
104
|
+
if (tagName === 'A' && href && href.startsWith('https://developer.mozilla.org/en-US/play')) {
|
|
81
105
|
return;
|
|
82
106
|
}
|
|
83
107
|
|
|
84
108
|
if (ALLOWED_TAGS.includes(tagName)) {
|
|
109
|
+
// Special case: B or STRONG with font-weight: normal should be unwrapped
|
|
110
|
+
if (tagName === 'B' || tagName === 'STRONG') {
|
|
111
|
+
const style = sourceNode.getAttribute('style') || '';
|
|
112
|
+
if (/font-weight\s*:\s*(normal|400|lighter)/i.test(style)) {
|
|
113
|
+
Array.from(sourceNode.childNodes).forEach(child => {
|
|
114
|
+
processNode(child, targetParent);
|
|
115
|
+
});
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Special case: I or EM with font-style: normal should be unwrapped
|
|
121
|
+
if (tagName === 'I' || tagName === 'EM') {
|
|
122
|
+
const style = sourceNode.getAttribute('style') || '';
|
|
123
|
+
if (/font-style\s*:\s*normal/i.test(style)) {
|
|
124
|
+
Array.from(sourceNode.childNodes).forEach(child => {
|
|
125
|
+
processNode(child, targetParent);
|
|
126
|
+
});
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
85
131
|
// Special case: UL/OL without LI children (often a bug in clipboard content)
|
|
86
132
|
// This tweak should only happen when this element is the FIRST element in the received DOM.
|
|
87
133
|
if (tagName === 'UL' || tagName === 'OL') {
|
|
88
134
|
const parent = sourceNode.parentNode;
|
|
89
|
-
const isFirstElementInBody =
|
|
90
|
-
|
|
91
|
-
Array.from(parent.children).find(c => !['META', 'STYLE'].includes(c.tagName.toUpperCase())) === sourceNode;
|
|
135
|
+
const isFirstElementInBody =
|
|
136
|
+
parent && parent.tagName === 'BODY' && Array.from(parent.children).find(c => !['META', 'STYLE'].includes(c.tagName.toUpperCase())) === sourceNode;
|
|
92
137
|
|
|
93
138
|
if (isFirstElementInBody) {
|
|
94
|
-
const hasLiChild = Array.from(sourceNode.childNodes).some(
|
|
95
|
-
child.nodeType === NodeGlobal.ELEMENT_NODE && child.tagName.toUpperCase() === 'LI'
|
|
139
|
+
const hasLiChild = Array.from(sourceNode.childNodes).some(
|
|
140
|
+
child => child.nodeType === NodeGlobal.ELEMENT_NODE && child.tagName.toUpperCase() === 'LI'
|
|
96
141
|
);
|
|
97
142
|
if (!hasLiChild) {
|
|
98
143
|
// Unwrap: process children directly into targetParent
|
|
@@ -137,9 +182,19 @@ function processNode(sourceNode, targetParent) {
|
|
|
137
182
|
export function removeStyleAttributes(html) {
|
|
138
183
|
const doc = parseHTMLGlobal(html);
|
|
139
184
|
const body = doc.body;
|
|
185
|
+
|
|
186
|
+
// Strip dangerous tags even when "Clean HTML" is off
|
|
187
|
+
const DANGEROUS_TAGS = ['SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'LINK', 'META'];
|
|
188
|
+
DANGEROUS_TAGS.forEach(tag => {
|
|
189
|
+
const elements = body.querySelectorAll(tag);
|
|
190
|
+
for (let i = 0; i < elements.length; i++) {
|
|
191
|
+
elements[i].remove();
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
140
195
|
const allElements = body.querySelectorAll('*');
|
|
141
196
|
for (let i = 0; i < allElements.length; i++) {
|
|
142
197
|
allElements[i].removeAttribute('style');
|
|
143
198
|
}
|
|
144
199
|
return body.innerHTML;
|
|
145
|
-
}
|
|
200
|
+
}
|
package/src/converter.js
CHANGED
package/src/index.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* index.js
|
|
3
|
+
* MarkPaste Library Entry Point (Node.js)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {cleanHTML, removeStyleAttributes} from './cleaner.js';
|
|
7
|
+
import {getConverter} from './converter.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Converts HTML to Markdown using the specified converter.
|
|
11
|
+
* @param {string} input The HTML (or Markdown) string to convert.
|
|
12
|
+
* @param {Object} options Configuration options.
|
|
13
|
+
* @param {string} [options.converter='turndown'] The converter to use ('turndown', 'pandoc').
|
|
14
|
+
* @param {boolean} [options.clean=true] Whether to clean the HTML before conversion.
|
|
15
|
+
* @param {boolean} [options.isMarkdown] Force treatment as markdown (skipping conversion).
|
|
16
|
+
* @returns {Promise<string>} The resulting Markdown string.
|
|
17
|
+
*/
|
|
18
|
+
export async function convert(input, options = {}) {
|
|
19
|
+
const {converter: converterName = 'turndown', clean = true, isMarkdown: forcedIsMarkdown} = options;
|
|
20
|
+
|
|
21
|
+
const isMarkdown = forcedIsMarkdown !== undefined ? forcedIsMarkdown : isProbablyMarkdown(input);
|
|
22
|
+
|
|
23
|
+
if (isMarkdown) {
|
|
24
|
+
return input;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const cleanedHtml = clean ? await cleanHTML(input) : await removeStyleAttributes(input);
|
|
28
|
+
const converter = await getConverter(converterName);
|
|
29
|
+
|
|
30
|
+
return converter.convert(cleanedHtml);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Heuristic to detect if a string is likely Markdown instead of HTML.
|
|
35
|
+
* @param {string} text
|
|
36
|
+
* @param {boolean} [hasHtmlFlavor=false] If we know for a fact there was an HTML flavor (e.g. from clipboard)
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
export function isProbablyMarkdown(text, hasHtmlFlavor = false) {
|
|
40
|
+
if (hasHtmlFlavor) return false;
|
|
41
|
+
const trimmed = text.trim();
|
|
42
|
+
if (trimmed.startsWith('<')) return false;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export {cleanHTML, removeStyleAttributes, getConverter};
|
package/src/pandoc.js
CHANGED
|
@@ -47,7 +47,7 @@ async function loadWasm() {
|
|
|
47
47
|
} else {
|
|
48
48
|
const fs = await import('node:fs');
|
|
49
49
|
const path = await import('node:path');
|
|
50
|
-
const {
|
|
50
|
+
const {fileURLToPath} = await import('node:url');
|
|
51
51
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
52
52
|
const wasmPath = path.join(__dirname, '..', 'third_party', 'pandoc.wasm');
|
|
53
53
|
const bytes = fs.readFileSync(wasmPath);
|
|
@@ -95,4 +95,4 @@ export function dispose() {
|
|
|
95
95
|
out_file = null;
|
|
96
96
|
wasi = null;
|
|
97
97
|
instance = null;
|
|
98
|
-
}
|
|
98
|
+
}
|
package/src/renderer.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {marked} from 'marked';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Renders markdown into a target element, sanitizing it first.
|
|
@@ -12,17 +12,17 @@ export async function renderMarkdown(markdown, targetElement) {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
const rawHtml = await marked.parse(markdown);
|
|
15
|
-
|
|
15
|
+
|
|
16
16
|
// @ts-ignore
|
|
17
17
|
if (targetElement.setHTML) {
|
|
18
18
|
// @ts-ignore
|
|
19
19
|
const sanitizer = new Sanitizer();
|
|
20
20
|
// @ts-ignore
|
|
21
|
-
targetElement.setHTML(rawHtml, {
|
|
21
|
+
targetElement.setHTML(rawHtml, {sanitizer});
|
|
22
22
|
} else {
|
|
23
23
|
// Fallback if setHTML/Sanitizer is not supported (though we should encourage it)
|
|
24
24
|
// For now, we will just set innerHTML as a fallback or warn.
|
|
25
|
-
// Given the prompt asks for Sanitizer API, we assume it's available or polyfilled,
|
|
25
|
+
// Given the prompt asks for Sanitizer API, we assume it's available or polyfilled,
|
|
26
26
|
// but in reality it's very experimental.
|
|
27
27
|
// We'll stick to the requested API.
|
|
28
28
|
console.warn('Sanitizer API (setHTML) not supported. Falling back to innerHTML (UNSAFE).');
|
package/src/style.css
CHANGED
|
@@ -156,6 +156,7 @@ main.vertical-layout {
|
|
|
156
156
|
min-height: 200px; /* Minimum height for outputs */
|
|
157
157
|
display: flex;
|
|
158
158
|
gap: 1rem;
|
|
159
|
+
position: relative;
|
|
159
160
|
}
|
|
160
161
|
|
|
161
162
|
.section-copy-actions {
|
|
@@ -275,6 +276,15 @@ main.vertical-layout {
|
|
|
275
276
|
margin: revert;
|
|
276
277
|
padding: revert;
|
|
277
278
|
}
|
|
279
|
+
|
|
280
|
+
pre, code {
|
|
281
|
+
background: var(--secondary-bg);
|
|
282
|
+
color: var(--text-color);
|
|
283
|
+
padding: 0.2rem 0.4rem;
|
|
284
|
+
border-radius: 4px;
|
|
285
|
+
font-family: var(--font-mono);
|
|
286
|
+
font-size: 90%;
|
|
287
|
+
}
|
|
278
288
|
}
|
|
279
289
|
|
|
280
290
|
|
|
@@ -288,6 +298,39 @@ main.vertical-layout {
|
|
|
288
298
|
display: none !important;
|
|
289
299
|
}
|
|
290
300
|
|
|
301
|
+
.loading-overlay {
|
|
302
|
+
position: absolute;
|
|
303
|
+
top: 0;
|
|
304
|
+
left: 0;
|
|
305
|
+
right: 0;
|
|
306
|
+
bottom: 0;
|
|
307
|
+
background: var(--panel-bg);
|
|
308
|
+
display: flex;
|
|
309
|
+
flex-direction: column;
|
|
310
|
+
align-items: center;
|
|
311
|
+
justify-content: center;
|
|
312
|
+
z-index: 10;
|
|
313
|
+
backdrop-filter: blur(4px);
|
|
314
|
+
border-radius: 6px;
|
|
315
|
+
color: var(--text-color);
|
|
316
|
+
font-weight: 500;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.spinner {
|
|
320
|
+
width: 40px;
|
|
321
|
+
height: 40px;
|
|
322
|
+
border: 4px solid var(--secondary-bg);
|
|
323
|
+
border-top: 4px solid var(--primary-color);
|
|
324
|
+
border-radius: 50%;
|
|
325
|
+
animation: spin 1s linear infinite;
|
|
326
|
+
margin-bottom: 1rem;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
@keyframes spin {
|
|
330
|
+
0% { transform: rotate(0deg); }
|
|
331
|
+
100% { transform: rotate(360deg); }
|
|
332
|
+
}
|
|
333
|
+
|
|
291
334
|
/* App Footer */
|
|
292
335
|
.app-footer {
|
|
293
336
|
display: flex;
|