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/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
- return /** @type {import('typed-query-selector/parser.js').ParseSelector<T, Element>} */ (result);
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 {$, $$ } = window;
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
- 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;
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.start({
147
- threshold: 600000,
148
- signal,
149
- }).catch(err => {
150
- console.warn('Idle detection start failed:', err);
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
- processContent(lastProcessedContent);
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
- processContent(lastProcessedContent);
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 content = pastedHtml || pastedText;
270
+ const isMarkdown = isProbablyMarkdown(pastedText, !!pastedHtml);
271
+ const content = isMarkdown ? pastedText : pastedHtml || pastedText;
238
272
  lastProcessedContent = content;
239
- processContent(content);
240
273
 
241
- // Reset scroll position for all pre elements
242
- $$('pre').forEach(pre => pre.scrollTop = 0);
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 processContent(html) {
252
- const shouldClean = cleanHtmlToggle.checked;
253
- const contentToConvert = shouldClean ? cleanHTML(html) : removeStyleAttributes(html);
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(contentToConvert);
326
+ htmlCode.textContent = formatHTML(htmlToShow);
257
327
  if (window.Prism) {
258
328
  window.Prism.highlightElement(htmlCode);
259
329
  }
260
330
 
261
- // Run all converters
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
- const markdown = converter.convert(contentToConvert);
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
- outputs[name].code.textContent = `Error converting with ${name}: ${err.message}`;
359
+ results[name] = `Error converting with ${name}: ${err.message}`;
273
360
  }
274
361
  }
275
362
  }
363
+ return results;
364
+ }
276
365
 
277
- if (currentView === 'rendered') {
278
- updateRenderedPreviews();
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
- copyBtn.innerHTML = originalText;
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 = (html) => {
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 { parseHTML } = await import('linkedom');
19
- parseHTMLGlobal = (html) => {
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', 'STRONG', 'B', 'EM', 'I', 'BLOCKQUOTE', 'CODE', 'PRE', 'A', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
30
- 'UL', 'OL', 'LI', 'DL', 'DT', 'DD', 'BR', 'HR', 'TABLE', 'THEAD', 'TBODY', 'TR', 'TH', 'TD',
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 = parent &&
90
- parent.tagName === 'BODY' &&
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(child =>
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
@@ -43,7 +43,7 @@ async function getPandocConverter() {
43
43
  },
44
44
  dispose: () => {
45
45
  pandocModule.dispose();
46
- }
46
+ },
47
47
  };
48
48
  }
49
49
 
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 { fileURLToPath } = await import('node:url');
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 { marked } from 'marked';
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, { sanitizer });
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;