markpaste 0.0.1 → 0.0.2

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/.nojekyll ADDED
File without changes
package/AGENTS.md CHANGED
@@ -13,9 +13,10 @@ MarkPaste is a web-based utility for converting rich text to Markdown. The core
13
13
  - `src/cleaner.js`: The logic for cleaning the HTML.
14
14
  - `src/converter.js`: The logic for converting HTML to Markdown.
15
15
 
16
- - `src/markpaste.js`: The library entry point. (for node or bundling)
16
+ - `src/index.js`: The library entry point. (for node or bundling)
17
17
 
18
18
  - Also:
19
+ - `bin/markpaste`: The CLI tool for macOS clipboard integration.
19
20
  - `test/web/`: Playwright tests for core application functionality.
20
21
  - `test/node/`: Node.js unit tests for library logic.
21
22
  - `third_party/`: External assets like `pandoc.wasm`.
package/README.md CHANGED
@@ -13,16 +13,18 @@ MarkPaste is an isomorphic tool that converts rich text to Markdown. It works se
13
13
  ## Usage
14
14
 
15
15
  ### Web Application
16
+
16
17
  1. **Open the app:** Run locally or host the `index.html`.
17
18
  2. **Paste:** Copy rich text from a webpage or document and paste it into the input area.
18
19
  3. **Get Markdown:** View the generated Markdown from multiple converters simultaneously.
19
20
  4. **Copy:** Click to copy the Markdown to your clipboard.
20
21
 
21
22
  ### Node.js Library
23
+
22
24
  MarkPaste can be used programmatically in Node.js environments. It uses `linkedom` to provide a lightweight DOM implementation for the conversion logic.
23
25
 
24
26
  ```javascript
25
- import { convert } from './src/markpaste.js';
27
+ import {convert} from './src/index.js';
26
28
 
27
29
  const html = '<h1>Hello World</h1><p>This is <b>bold</b> text.</p>';
28
30
 
@@ -31,11 +33,11 @@ const markdown = await convert(html);
31
33
  console.log(markdown);
32
34
 
33
35
  // Using Pandoc
34
- const pandocMarkdown = await convert(html, { converter: 'pandoc' });
36
+ const pandocMarkdown = await convert(html, {converter: 'pandoc'});
35
37
  console.log(pandocMarkdown);
36
38
 
37
39
  // Disabling HTML cleaning
38
- const rawMarkdown = await convert(html, { clean: false });
40
+ const rawMarkdown = await convert(html, {clean: false});
39
41
  ```
40
42
 
41
43
  ## Development
@@ -43,6 +45,7 @@ const rawMarkdown = await convert(html, { clean: false });
43
45
  To run MarkPaste locally:
44
46
 
45
47
  1. **Clone & Install:**
48
+
46
49
  ```bash
47
50
  git clone https://github.com/paulirish/markpaste.git
48
51
  cd markpaste
@@ -56,11 +59,14 @@ To run MarkPaste locally:
56
59
  Access the application at `http://localhost:7025`.
57
60
 
58
61
  ### Testing
62
+
59
63
  The project uses a dual-testing strategy:
64
+
60
65
  - **Node.js tests:** Unit tests for isomorphic modules (`pnpm test:node`).
61
66
  - **Playwright tests:** End-to-end browser testing (`pnpm test:web`).
62
67
 
63
68
  Run all tests with:
69
+
64
70
  ```bash
65
71
  pnpm test
66
72
  ```
package/TYPESCRIPT.md ADDED
@@ -0,0 +1,109 @@
1
+ # Guide: Modern Type Checking without a Build Step
2
+
3
+ This guide outlines a setup for achieving type safety in both browser and Node.js environments while avoiding a compilation/build step.
4
+
5
+ ## Core Philosophy
6
+
7
+ 1. **Browser (Client-side)**: Use pure JavaScript with **JSDoc annotations**. This ensures the code runs directly in the browser without bundling or transpilation.
8
+ 2. **Node.js (Server-side/Tooling)**: Use **TypeScript with Erasable Syntax**. This allows Node.js to execute `.ts` files natively while benefiting from TypeScript's static analysis.
9
+
10
+ ---
11
+
12
+ ## 1. Browser-side: JavaScript + JSDoc
13
+
14
+ Maintain all frontend logic in `.js` files. Use JSDoc to define types, interfaces, and function signatures.
15
+
16
+ ### Patterns
17
+
18
+ - Use `@type` for variable declarations.
19
+ - Use `@param` and `@returns` for functions.
20
+ - Use `@typedef` or `@template` for complex types.
21
+ - Import types from other files using `import('./path/to/file.js').TypeName`.
22
+
23
+ ```javascript
24
+ /**
25
+ * @param {string} input
26
+ * @param {Object} options
27
+ * @param {boolean} [options.verbose]
28
+ * @returns {Promise<string>}
29
+ */
30
+ export async function processData(input, {verbose = false} = {}) {
31
+ // ...
32
+ }
33
+ ```
34
+
35
+ ---
36
+
37
+ ## 2. Node.js: TypeScript + Erasable Syntax
38
+
39
+ Use `.ts` files for Node.js logic. Stick to **Erasable Syntax Only**—features that can be removed by simply stripping the type annotations.
40
+
41
+ ### Prohibited Features (Non-Erasable)
42
+
43
+ - `enum`
44
+ - `namespaces`
45
+ - `parameter properties` in constructors (e.g., `constructor(public name: string) {}`)
46
+ - `experimentalDecorators`
47
+
48
+ ### Required Conventions
49
+
50
+ - **Explicit Extensions**: Always use the `.ts` extension in import statements.
51
+ - **Explicit Type Imports**: Always use `import type` for types to ensure they are safely erasable.
52
+
53
+ ```typescript
54
+ import {helper} from './utils.ts';
55
+ import type {Config} from './types.ts';
56
+ ```
57
+
58
+ ---
59
+
60
+ ## 3. Configuration Requirements
61
+
62
+ ### `tsconfig.json`
63
+
64
+ The configuration must support both JSDoc checking and native TypeScript execution.
65
+
66
+ ```json
67
+ {
68
+ "compilerOptions": {
69
+ "target": "esnext",
70
+ "module": "nodenext",
71
+ /* Native TS Execution Flags */
72
+ "erasableSyntaxOnly": true,
73
+ "verbatimModuleSyntax": true,
74
+ "allowImportingTsExtensions": true,
75
+ "rewriteRelativeImportExtensions": true,
76
+
77
+ /* Type Checking Strategy */
78
+ "noEmit": true,
79
+ "allowJs": true,
80
+ "checkJs": true,
81
+ "skipLibCheck": true,
82
+ "strict": true
83
+ }
84
+ }
85
+ ```
86
+
87
+ ### `package.json`
88
+
89
+ The project must be an ES Module.
90
+
91
+ ```json
92
+ {
93
+ "type": "module",
94
+ "engines": {
95
+ "node": ">=24.11.0"
96
+ },
97
+ "scripts": {
98
+ "typecheck": "tsc --noEmit"
99
+ }
100
+ }
101
+ ```
102
+
103
+ ---
104
+
105
+ ## 4. Environment & Tooling
106
+
107
+ - **Node.js**: Version **v24.11.0+** is required for stable native TypeScript support.
108
+ - **IDE**: VS Code (or any LSP-compliant editor) will automatically pick up the `tsconfig.json` to provide real-time feedback for both `.js` (via JSDoc) and `.ts` files.
109
+ - **CI/CD**: Run `npm run typecheck` (or equivalent) as a blocking step in your pipeline.
package/bin/markpaste ADDED
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * markpaste
5
+ *
6
+ * 1. Reads HTML from the clipboard (via raw hex from osascript)
7
+ * 2. Cleans and converts it to Markdown using MarkPaste
8
+ * 3. Saves Markdown as Plain Text and Cleaned HTML as HTML back to clipboard
9
+ * 4. Verifies the set content.
10
+ */
11
+
12
+ import { execSync, spawnSync } from 'node:child_process';
13
+ import { writeFileSync, unlinkSync } from 'node:fs';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import os from 'node:os';
17
+
18
+ import { convert, cleanHTML, isProbablyMarkdown } from '../src/index.js';
19
+ import { marked } from 'marked';
20
+
21
+ /**
22
+ * Formats a string for preview in the logs (truncated, escaped, and dimmed).
23
+ */
24
+ function textToPreview(text, length = 100) {
25
+ const dimmedStart = '\x1b[2m';
26
+ const dimmedEnd = '\x1b[22m';
27
+ const preview = text.substring(0, length).replace(/\n/g, '\\n');
28
+ return `${dimmedStart}${preview}${dimmedEnd}...`;
29
+ }
30
+
31
+ /**
32
+ * Reads the HTML flavor from the macOS clipboard.
33
+ * Returns null if no HTML flavor is present.
34
+ */
35
+ function readHtmlFromClipboard() {
36
+ try {
37
+ const script = 'try\n the clipboard as «class HTML»\non error\n return ""\nend try';
38
+ const hexData = execSync(`osascript -e '${script}'`, { encoding: 'utf8' }).trim();
39
+ const match = hexData.match(/«data HTML([0-9A-F]*)»/i);
40
+ if (match && match[1]) {
41
+ return Buffer.from(match[1], 'hex').toString('utf8');
42
+ }
43
+ } catch (e) {
44
+ // Silently fail to return null
45
+ }
46
+ return null;
47
+ }
48
+
49
+ /**
50
+ * Reads the plain text flavor from the macOS clipboard.
51
+ */
52
+ function readTextFromClipboard() {
53
+ return execSync('pbpaste', { encoding: 'utf8' });
54
+ }
55
+
56
+ /**
57
+ * Sets both the HTML and Plain Text flavors on the macOS clipboard.
58
+ */
59
+ function setClipboard(html, markdown) {
60
+ const mdTmp = path.join(os.tmpdir(), `mp_md_${Date.now()}.txt`);
61
+ writeFileSync(mdTmp, markdown);
62
+
63
+ const htmlHex = Buffer.from(html, 'utf8').toString('hex');
64
+ const appleScript = `
65
+ set theText to (read (POSIX file "${mdTmp}") as «class utf8»)
66
+ set the clipboard to {«class HTML»:«data HTML${htmlHex}», text:theText}
67
+ `;
68
+
69
+ const result = spawnSync('osascript', [], { input: appleScript, encoding: 'utf8' });
70
+ unlinkSync(mdTmp);
71
+
72
+ if (result.status !== 0) {
73
+ throw new Error(`osascript failed: ${result.stderr}`);
74
+ }
75
+ }
76
+
77
+ async function main() {
78
+ try {
79
+ const rawHtml = readHtmlFromClipboard();
80
+ const hasHtmlFlavor = !!rawHtml;
81
+ const input = rawHtml || readTextFromClipboard();
82
+
83
+ if (!input || input.trim() === '') {
84
+ console.error('No content found in clipboard.');
85
+ process.exit(1);
86
+ }
87
+
88
+ console.log(`Reading from clipboard... (${input.length} chars)`);
89
+ console.log(` Preview: ${textToPreview(input)}`);
90
+
91
+ const isMarkdown = isProbablyMarkdown(input, hasHtmlFlavor);
92
+
93
+ let cleanedHtml;
94
+ let markdown;
95
+
96
+ if (isMarkdown) {
97
+ console.log('✨ Markdown detected. Rendering to HTML...');
98
+ markdown = input;
99
+ cleanedHtml = await marked.parse(input);
100
+ } else {
101
+ cleanedHtml = await cleanHTML(input);
102
+ markdown = await convert(input, { clean: true, isMarkdown: false });
103
+ }
104
+
105
+ console.log('Updating clipboard...');
106
+ setClipboard(cleanedHtml, markdown);
107
+
108
+ // // Verification Step
109
+ // const verifiedHtml = readHtmlFromClipboard();
110
+ // if (verifiedHtml === cleanedHtml) {
111
+ // console.log('✅ Clipboard verified: HTML flavor matches perfectly.');
112
+ // } else {
113
+ // console.warn('⚠️ Clipboard verification mismatch!');
114
+ // if (!verifiedHtml) {
115
+ // console.warn(' Reason: No HTML flavor found after setting.');
116
+ // } else {
117
+ // console.warn(` Reason: Length difference (${cleanedHtml.length} vs ${verifiedHtml.length})`);
118
+ // }
119
+ // }
120
+
121
+ console.log('\n✅ Clipboard updated with:');
122
+ console.log(` - Plain Text (Markdown): ${textToPreview(markdown)}`);
123
+ console.log(` - HTML (Cleaned): ${textToPreview(cleanedHtml)}`);
124
+
125
+ } catch (err) {
126
+ console.error('Error during conversion:', err);
127
+ process.exit(1);
128
+ }
129
+ }
130
+
131
+ main();
package/index.html CHANGED
@@ -56,6 +56,10 @@
56
56
 
57
57
  <!-- Bottom: 2 Columns Output -->
58
58
  <section class="section-outputs two-column-grid">
59
+ <div id="loadingOverlay" class="loading-overlay hidden">
60
+ <div class="spinner"></div>
61
+ <span>Converting...</span>
62
+ </div>
59
63
  <!-- Turndown -->
60
64
  <div class="output-column">
61
65
  <div class="panel output-panel">
@@ -135,10 +139,10 @@
135
139
  <script type="importmap">
136
140
  {
137
141
  "imports": {
138
- "marked": "/node_modules/marked/lib/marked.esm.js",
139
- "turndown": "/node_modules/turndown/lib/turndown.es.js",
140
- "turndown-plugin-gfm": "/node_modules/turndown-plugin-gfm/lib/turndown-plugin-gfm.es.js",
141
- "@bjorn3/browser_wasi_shim": "/node_modules/@bjorn3/browser_wasi_shim/dist/index.js"
142
+ "marked": "https://unpkg.com/marked@15.0.0/lib/marked.esm.js",
143
+ "turndown": "https://unpkg.com/turndown@7.2.0/lib/turndown.es.js",
144
+ "turndown-plugin-gfm": "https://unpkg.com/turndown-plugin-gfm@1.0.2/lib/turndown-plugin-gfm.es.js",
145
+ "@bjorn3/browser_wasi_shim": "https://unpkg.com/@bjorn3/browser_wasi_shim@0.4.0/dist/index.js"
142
146
  }
143
147
  }
144
148
  </script>
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "markpaste",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "HTML and Markdown, perfected for pasting into documents, emails, and more. So isomorphic. lol.",
5
- "main": "src/markpaste.js",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "markpaste": "bin/markpaste"
8
+ },
6
9
  "scripts": {
7
10
  "start": "npx statikk --port 7025",
8
11
  "typecheck": "tsc --noEmit",
9
- "format": "prettier --write .",
12
+ "format": "prettier --write \"**/*.{js,ts,json,md}\"",
10
13
  "test": "pnpm run typecheck && pnpm run test:node && pnpm run test:web",
11
14
  "test:web": "playwright test",
12
15
  "test:node": "node --test test/node/**/*.test.js"
@@ -0,0 +1,2 @@
1
+ overrides:
2
+ markpaste: 'link:'
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,12 +99,9 @@ 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
 
@@ -86,13 +110,12 @@ function processNode(sourceNode, targetParent) {
86
110
  // This tweak should only happen when this element is the FIRST element in the received DOM.
87
111
  if (tagName === 'UL' || tagName === 'OL') {
88
112
  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;
113
+ const isFirstElementInBody =
114
+ parent && parent.tagName === 'BODY' && Array.from(parent.children).find(c => !['META', 'STYLE'].includes(c.tagName.toUpperCase())) === sourceNode;
92
115
 
93
116
  if (isFirstElementInBody) {
94
- const hasLiChild = Array.from(sourceNode.childNodes).some(child =>
95
- child.nodeType === NodeGlobal.ELEMENT_NODE && child.tagName.toUpperCase() === 'LI'
117
+ const hasLiChild = Array.from(sourceNode.childNodes).some(
118
+ child => child.nodeType === NodeGlobal.ELEMENT_NODE && child.tagName.toUpperCase() === 'LI'
96
119
  );
97
120
  if (!hasLiChild) {
98
121
  // Unwrap: process children directly into targetParent
@@ -142,4 +165,4 @@ export function removeStyleAttributes(html) {
142
165
  allElements[i].removeAttribute('style');
143
166
  }
144
167
  return body.innerHTML;
145
- }
168
+ }
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;
@@ -1,6 +1,6 @@
1
- import { test } from 'node:test';
1
+ import {test} from 'node:test';
2
2
  import assert from 'node:assert';
3
- import { cleanHTML, removeStyleAttributes } from '../../src/cleaner.js';
3
+ import {cleanHTML, removeStyleAttributes} from '../../src/cleaner.js';
4
4
 
5
5
  test('cleaner: cleanHTML should remove disallowed tags', async () => {
6
6
  const html = '<div><p>Hello</p><script>alert(1)</script><span>World</span></div>';
@@ -36,9 +36,7 @@ test('cleaner: removeStyleAttributes should strip style attributes', async () =>
36
36
  assert.strictEqual(stripped.toLowerCase().includes('<p>hello</p>'), true);
37
37
  });
38
38
 
39
-
40
39
  test('cleaner: handles leading OL/UL tags correctly', async () => {
41
-
42
40
  const html = `<meta charset='utf-8'><ol style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; margin: 0px; padding-left: 40px; list-style: outside decimal; color: rgb(32, 33, 36); font-family: Roboto, &quot;Noto Sans&quot;, &quot;Noto Sans JP&quot;, &quot;Noto Sans KR&quot;, &quot;Noto Naskh Arabic&quot;, &quot;Noto Sans Thai&quot;, &quot;Noto Sans Hebrew&quot;, &quot;Noto Sans Bengali&quot;, sans-serif; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(255, 255, 255); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><h1 class="devsite-page-title" tabindex="-1" style="box-sizing: inherit; font: 500 36px / 44px &quot;Google Sans&quot;, &quot;Noto Sans&quot;, &quot;Noto Sans JP&quot;, &quot;Noto Sans KR&quot;, &quot;Noto Naskh Arabic&quot;, &quot;Noto Sans Thai&quot;, &quot;Noto Sans Hebrew&quot;, &quot;Noto Sans Bengali&quot;, sans-serif; color: rgb(32, 33, 36); letter-spacing: normal; margin: 0px 0px 24px; overflow: visible; text-overflow: ellipsis; display: inline; vertical-align: middle;">Manage Python packages<devsite-actions data-nosnippet="" style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; display: inline-flex; gap: 8px; padding-inline: 8px;"><devsite-feature-tooltip ack-key="AckCollectionsBookmarkTooltipDismiss" analytics-category="Site-Wide Custom Events" analytics-action-show="Callout Profile displayed" analytics-action-close="Callout Profile dismissed" analytics-label="Create Collection Callout" class="devsite-page-bookmark-tooltip nocontent inline-block" dismiss-button="true" id="devsite-collections-dropdown" dismiss-button-text="Dismiss" close-button-text="Got it" rendered="" current-step="0" style="--devsite-popout-top: calc(100% + 17px); --devsite-popout-width: min(50vw,320px); --devsite-feature-tooltip-text-color: #fff; position: relative; box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; display: inline-block; --devsite-popout-offset-x: 32px;"><slot><devsite-bookmark class="show" style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; --devsite-bookmark-background: 0; --devsite-bookmark-background-focus-legacy: #e8eaed; --devsite-bookmark-background-hover-legacy: #f1f3f4; --devsite-bookmark-icon-color: #5f6368; --devsite-bookmark-icon-color-saved: #1a73e8; --devsite-bookmark-icon-color-saved-hover: #174ea6; --devsite-dropdown-list-toggle-background-hover: #f1f3f4; --devsite-dropdown-list-toggle-border: 1px solid #dadce0; --devsite-dropdown-list-toggle-border-hover: 1px solid #dadce0; --devsite-dropdown-list-toggle-height: 36px; display: inline-flex; -webkit-box-align: center; align-items: center; background: none 0px 50% / auto repeat scroll padding-box border-box rgba(0, 0, 0, 0); border: 0px; box-shadow: none; cursor: pointer; height: 36px; -webkit-box-pack: center; justify-content: center; margin: 0px; padding: 0px; vertical-align: middle;"><devsite-dropdown-list aria-label="Bookmark collections drop down" ellipsis="" checkboxes="" fetchingitems="true" writable="" additemtext="New Collection" rendered="" style="--devsite-checkbox-icon-canvas-offset-x: -10px; --devsite-checkbox-icon-canvas-offset-y: -8px; --devsite-checkbox-offset-x: 4px; --devsite-checkbox-offset-y: -2px; --devsite-mdc-line-height: 50px; display: inline-flex; position: relative; vertical-align: middle; box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; --devsite-button-box-shadow: none; visibility: visible;"><button class="toggle-button button" aria-haspopup="menu" id="dropdown-list-0-toggle" aria-controls="dropdown-list-0-dropdown" aria-expanded="false" aria-label="Open dropdown" style="align-self: auto; appearance: none; background: 0px center; border-color: rgb(218, 220, 224); border-style: solid; border-width: 1px; border-image: none 100% / 1 / 0 stretch; border-radius: 4px; box-shadow: none; box-sizing: border-box; color: rgb(95, 99, 104); cursor: pointer; display: flex; font: 500 14px / 34px &quot;Google Sans&quot;, &quot;Noto Sans&quot;, &quot;Noto Sans JP&quot;, &quot;Noto Sans KR&quot;, &quot;Noto Naskh Arabic&quot;, &quot;Noto Sans Thai&quot;, &quot;Noto Sans Hebrew&quot;, &quot;Noto Sans Bengali&quot;, sans-serif; height: 36px; letter-spacing: normal; max-width: none; min-width: auto; outline: 0px; overflow: hidden; padding: 0px 3px; text-align: center; text-decoration: none; text-overflow: ellipsis; text-transform: none; transition: background-color 0.2s, border 0.2s, box-shadow 0.2s; vertical-align: middle; white-space: nowrap; width: auto; margin: 0px; margin-inline-end: 0px; -webkit-box-align: center; -webkit-box-pack: center; align-items: center; justify-content: center;"><slot name="toggle"><span data-label="devsite-bookmark-direct-action" data-title="Save page" class="material-icons bookmark-icon bookmark-action" slot="toggle" style="-webkit-font-smoothing: antialiased; text-rendering: optimizelegibility; overflow-wrap: normal; font-style: normal; font-variant: normal; font-size-adjust: none; font-language-override: normal; font-kerning: auto; font-optical-sizing: auto; font-feature-settings: &quot;liga&quot;; font-variation-settings: normal; font-weight: normal; font-stretch: normal; font-size: 24px; line-height: 1; font-family: &quot;Material Icons&quot;; text-transform: none; box-sizing: inherit; letter-spacing: normal; display: inline-block; white-space: nowrap; direction: ltr; color: rgb(95, 99, 104); transition: color 0.2s; vertical-align: bottom;">bookmark_border</span></slot></button></devsite-dropdown-list></devsite-bookmark></slot></devsite-feature-tooltip></devsite-actions></h1><devsite-toc class="devsite-nav devsite-toc-embedded" depth="2" devsite-toc-embedded="" visible="" style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; font-size: 13px; display: block; margin: 28px 0px 24px;"><ul class="devsite-nav-list" style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; margin: 0px; padding: 0px; list-style: outside none; border-inline-start: 4px solid rgb(25, 103, 210); width: auto; padding-inline-start: 12px;"><li class="devsite-nav-item devsite-nav-heading devsite-toc-toggle" style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; margin: 0px; padding: 0px; line-height: 16px; display: flex;"></li><li class="devsite-nav-item" visible="" style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; margin: 0px; padding: 0px; line-height: 16px; display: block;"></li><li class="devsite-toc-toggle" style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; margin: 0px; padding: 0px; display: flex;"></li></ul></devsite-toc><div class="devsite-article-body clearfix" style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; margin: 16px 0px 0px; padding: 0px;"><p style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; margin: 0px 0px 16px; padding: 0px; color: rgb(32, 33, 36); font-family: Roboto, &quot;Noto Sans&quot;, &quot;Noto Sans JP&quot;, &quot;Noto Sans KR&quot;, &quot;Noto Naskh Arabic&quot;, &quot;Noto Sans Thai&quot;, &quot;Noto Sans Hebrew&quot;, &quot;Noto Sans Bengali&quot;, sans-serif; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(255, 255, 255); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><a href="https://pypi.org/" class="external" style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; color: rgb(26, 115, 232); outline: 0px; text-decoration: rgb(26, 115, 232); word-break: break-word;">Python Package Index</a><span> </span>(PyPI) is a public repository for Python packages. You can use Artifact Registry to create private repositories for your Python packages. These private repositories use the canonical Python repository implementation, the<span> </span><a href="https://www.python.org/dev/peps/pep-0503/" class="external" style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; color: rgb(26, 115, 232); outline: 0px; text-decoration: rgb(26, 115, 232); word-break: break-word;">simple repository API</a><span> </span>(PEP 503), and work with installation tools like<span> </span><code translate="no" dir="ltr" style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; background: none 0% 0% / auto repeat scroll padding-box border-box rgb(241, 243, 244); color: rgb(55, 71, 79); font-style: normal; font-variant: normal; font-weight: 500; font-stretch: 100%; font-size: 14.4px; line-height: 14.4px; font-family: &quot;Roboto Mono&quot;, monospace; font-optical-sizing: auto; font-size-adjust: none; font-kerning: auto; font-variation-settings: normal; font-language-override: normal; padding: 1px 4px; direction: ltr !important; text-align: left !important; border-color: rgb(55, 71, 79); border-style: none; border-width: 0px; border-image: none 100% / 1 / 0 stretch; border-radius: 0px; word-break: break-word;">pip</code>.</p><h2 id="overview" data-text="Overview" tabindex="-1" role="presentation" style="box-sizing: inherit; font: 400 24px / 32px &quot;Google Sans&quot;, &quot;Noto Sans&quot;, &quot;Noto Sans JP&quot;, &quot;Noto Sans KR&quot;, &quot;Noto Naskh Arabic&quot;, &quot;Noto Sans Thai&quot;, &quot;Noto Sans Hebrew&quot;, &quot;Noto Sans Bengali&quot;, sans-serif; letter-spacing: normal; margin: 48px 0px 24px; overflow-x: clip; text-overflow: ellipsis; border-bottom: 0px none rgb(32, 33, 36); padding: 0px; color: rgb(32, 33, 36); orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(255, 255, 255); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span class="devsite-heading" role="heading" aria-level="2" style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;;">Overview</span><button type="button" class="devsite-heading-link button-flat material-icons" aria-label="Copy link to this section: Overview" data-title="Copy link to this section: Overview" data-id="overview" style="box-sizing: border-box; font-feature-settings: &quot;liga&quot;; appearance: none; align-self: auto; background: none 0px 50% / auto repeat scroll padding-box border-box rgba(0, 0, 0, 0); border: 0px; border-radius: 4px; box-shadow: none; color: rgb(95, 99, 104); cursor: pointer; display: inline-block; font-style: normal; font-variant: normal; font-weight: normal; font-stretch: 100%; font-size: 24px; font-family: &quot;Material Icons&quot;; font-optical-sizing: auto; font-size-adjust: none; font-kerning: auto; font-variation-settings: normal; font-language-override: normal; height: 24px; letter-spacing: normal; line-height: 24px; margin: 0px; margin-inline-end: 0px; max-width: none; min-width: 36px; outline: 0px; overflow: hidden; padding: 0px 24px; text-align: center; text-decoration: none; text-overflow: ellipsis; text-transform: none; transition: background-color 0.2s, border 0.2s, box-shadow 0.2s; vertical-align: middle; white-space: nowrap; width: auto; overflow-wrap: normal; direction: ltr; -webkit-font-smoothing: antialiased; padding-inline: 8px; --devsite-button-white-line-height: 24px; --devsite-button-white-background-hover: transparent; opacity: 0;"></button></h2><p style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; margin: 16px 0px; padding: 0px; color: rgb(32, 33, 36); font-family: Roboto, &quot;Noto Sans&quot;, &quot;Noto Sans JP&quot;, &quot;Noto Sans KR&quot;, &quot;Noto Naskh Arabic&quot;, &quot;Noto Sans Thai&quot;, &quot;Noto Sans Hebrew&quot;, &quot;Noto Sans Bengali&quot;, sans-serif; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(255, 255, 255); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;">To get familiar with Python packages in Artifact Registry, you can try the<span> </span><a href="https://docs.cloud.google.com/artifact-registry/docs/python/quickstart" style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; color: rgb(26, 115, 232); outline: 0px; text-decoration: rgb(26, 115, 232); word-break: break-word;">quickstart</a>.</p><p style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; margin: 16px 0px; padding: 0px; color: rgb(32, 33, 36); font-family: Roboto, &quot;Noto Sans&quot;, &quot;Noto Sans JP&quot;, &quot;Noto Sans KR&quot;, &quot;Noto Naskh Arabic&quot;, &quot;Noto Sans Thai&quot;, &quot;Noto Sans Hebrew&quot;, &quot;Noto Sans Bengali&quot;, sans-serif; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(255, 255, 255); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;">When you are ready to learn more, read the following information:</p><ol style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; margin: 0px; padding-left: 40px; list-style: outside decimal; color: rgb(32, 33, 36); font-family: Roboto, &quot;Noto Sans&quot;, &quot;Noto Sans JP&quot;, &quot;Noto Sans KR&quot;, &quot;Noto Naskh Arabic&quot;, &quot;Noto Sans Thai&quot;, &quot;Noto Sans Hebrew&quot;, &quot;Noto Sans Bengali&quot;, sans-serif; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(255, 255, 255); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><li style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; margin: 12px 0px; padding: 0px;"><a href="https://docs.cloud.google.com/artifact-registry/docs/repositories/create-repos" style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; color: rgb(26, 115, 232); outline: 0px; text-decoration: rgb(26, 115, 232); word-break: break-word;">Create a Python package repository</a><span> </span>for your packages.</li><li style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; margin: 12px 0px; padding: 0px;"><a href="https://docs.cloud.google.com/artifact-registry/docs/access-control" style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; color: rgb(26, 115, 232); outline: 0px; text-decoration: rgb(26, 115, 232); word-break: break-word;">Grant permissions</a><span> </span>to the account that will connect with the repository. Service accounts for common integrations with Artifact Registry have default<span> </span><a href="https://docs.cloud.google.com/artifact-registry/docs/access-control#gcp" style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; color: rgb(26, 115, 232); outline: 0px; text-decoration: rgb(26, 115, 232); word-break: break-word;">permissions</a><span> </span>for repositories in the same project.</li><li style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; margin: 12px 0px; padding: 0px;">Configure your tools:<ul style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; margin: 0px; padding-left: 40px; list-style: outside disc;"><li style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; margin: 12px 0px; padding: 0px;"><a href="https://docs.cloud.google.com/artifact-registry/docs/python/authentication" style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; color: rgb(26, 115, 232); outline: 0px; text-decoration: rgb(26, 115, 232); word-break: break-word;">Configure authentication</a><span> </span>for Python clients that interact with the repository.</li><li style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; margin: 12px 0px; padding: 0px;"><a href="https://docs.cloud.google.com/artifact-registry/docs/configure-cloud-build" style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; color: rgb(26, 115, 232); outline: 0px; text-decoration: rgb(26, 115, 232); word-break: break-word;">Configure Cloud Build</a><span> </span>to upload and download packages.</li><li style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; margin: 12px 0px; padding: 0px;">Learn about<span> </span><a href="https://docs.cloud.google.com/artifact-registry/docs/deploy" style="box-sizing: inherit; font-feature-settings: &quot;dgun&quot;; color: rgb(26, 115, 232); outline: 0px; text-decoration: rgb(26, 115, 232); word-break: break-word;">deploying</a><span> </span>to Google Cloud runtime environments.</li></ul></li></ol><br class="Apple-interchange-newline">`;
43
41
 
44
42
  const cleaned = await cleanHTML(html);
@@ -47,4 +45,4 @@ test('cleaner: handles leading OL/UL tags correctly', async () => {
47
45
  assert.strictEqual(cleaned.toLowerCase().startsWith('<ol'), false);
48
46
  assert.strictEqual(cleaned.toLowerCase().includes('<h1'), true);
49
47
  assert.strictEqual(cleaned.toLowerCase().includes('manage python packages'), true);
50
- });
48
+ });
@@ -1,6 +1,6 @@
1
- import { test } from 'node:test';
1
+ import {test} from 'node:test';
2
2
  import assert from 'node:assert';
3
- import { getConverter } from '../../src/converter.js';
3
+ import {getConverter} from '../../src/converter.js';
4
4
 
5
5
  test('converter: turndown should convert HTML to Markdown', async () => {
6
6
  const converter = await getConverter('turndown');
@@ -10,9 +10,12 @@ test('converter: turndown should convert HTML to Markdown', async () => {
10
10
  });
11
11
 
12
12
  test('converter: unknown converter should throw error', async () => {
13
- await assert.rejects(async () => {
14
- await getConverter('non-existent');
15
- }, {
16
- message: 'Unknown converter: non-existent. Available converters: turndown, pandoc'
17
- });
13
+ await assert.rejects(
14
+ async () => {
15
+ await getConverter('non-existent');
16
+ },
17
+ {
18
+ message: 'Unknown converter: non-existent. Available converters: turndown, pandoc',
19
+ }
20
+ );
18
21
  });
@@ -1,6 +1,6 @@
1
- import { test } from 'node:test';
1
+ import {test} from 'node:test';
2
2
  import assert from 'node:assert';
3
- import { convert } from '../../src/markpaste.js';
3
+ import {convert} from '../../src/index.js';
4
4
 
5
5
  test('library: convert should use turndown by default', async () => {
6
6
  const html = '<h1>Hello</h1>';
@@ -10,7 +10,7 @@ test('library: convert should use turndown by default', async () => {
10
10
 
11
11
  test('library: convert should support pandoc', async () => {
12
12
  const html = '<p>Hello <b>World</b></p>';
13
- const markdown = await convert(html, { converter: 'pandoc' });
13
+ const markdown = await convert(html, {converter: 'pandoc'});
14
14
  assert.strictEqual(markdown.includes('Hello'), true);
15
15
  assert.strictEqual(markdown.includes('World'), true);
16
16
  });
@@ -18,6 +18,19 @@ test('library: convert should support pandoc', async () => {
18
18
  test('library: convert should support disabling cleaning', async () => {
19
19
  const html = '<div style="color: red;"><p>Hello</p></div>';
20
20
  // If clean is false, it uses removeStyleAttributes which unwraps but might keep some structure
21
- const markdown = await convert(html, { clean: false });
21
+ const markdown = await convert(html, {clean: false});
22
22
  assert.strictEqual(markdown.includes('Hello'), true);
23
23
  });
24
+
25
+ test('library: convert should short-circuit if markdown is detected', async () => {
26
+ const markdownInput = '# Already Markdown\n\n- item';
27
+ const result = await convert(markdownInput);
28
+ assert.strictEqual(result, markdownInput);
29
+ });
30
+
31
+ test('library: convert should NOT short-circuit if isMarkdown: false is passed', async () => {
32
+ const input = '# Not Markdown'; // Looks like MD, but we force it not to be
33
+ const result = await convert(input, {isMarkdown: false});
34
+ // Turndown escapes # if it's not a real header
35
+ assert.strictEqual(result.trim(), '\\# Not Markdown');
36
+ });
@@ -0,0 +1,72 @@
1
+ import {test} from 'node:test';
2
+ import assert from 'node:assert';
3
+ import {execSync, spawnSync} from 'node:child_process';
4
+ import path from 'node:path';
5
+ import {fileURLToPath} from 'node:url';
6
+ import os from 'node:os';
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const CLIP_TOOL = path.resolve(__dirname, '../../bin/markpaste');
10
+
11
+ // This test only works on macOS
12
+ if (os.platform() === 'darwin') {
13
+ test('markpaste cli: round-trip conversion', async () => {
14
+ const testContent = '<h1>Test</h1><p><b>Bold</b></p>';
15
+
16
+ // 1. Set initial clipboard state
17
+ const htmlHex = Buffer.from(testContent, 'utf8').toString('hex');
18
+ const setScript = `set the clipboard to {«class HTML»:«data HTML${htmlHex}», text:"${testContent}"}`;
19
+ spawnSync('osascript', ['-e', setScript]);
20
+
21
+ // 2. Run markpaste
22
+ execSync(`"${CLIP_TOOL}"`, {encoding: 'utf8'});
23
+
24
+ // 3. Verify Plain Text flavor (should be Markdown)
25
+ const plainText = execSync('osascript -e "get (the clipboard as string)"', {encoding: 'utf8'}).trim();
26
+ assert.strictEqual(plainText.includes('# Test'), true);
27
+ assert.strictEqual(plainText.includes('**Bold**'), true);
28
+
29
+ // 4. Verify HTML flavor (should be cleaned HTML)
30
+ const htmlData = execSync('osascript -e "get (the clipboard as «class HTML»)"', {encoding: 'utf8'}).trim();
31
+ assert.strictEqual(htmlData.includes('«data HTML'), true);
32
+
33
+ // Extract hex and convert back to string
34
+ const match = htmlData.match(/«data HTML([0-9A-F]*)»/i);
35
+ if (match && match[1]) {
36
+ const resultHtml = Buffer.from(match[1], 'hex').toString('utf8');
37
+ assert.strictEqual(resultHtml.toLowerCase().includes('<h1>test</h1>'), true);
38
+ assert.strictEqual(resultHtml.toLowerCase().includes('<b>bold</b>'), true);
39
+ } else {
40
+ assert.fail('Could not extract HTML hex from clipboard');
41
+ }
42
+ });
43
+
44
+ test('markpaste cli: markdown to html conversion', async () => {
45
+ const markdownInput = '# MD Test\n\n- item 1';
46
+
47
+ // 1. Set clipboard to plain text only (no HTML flavor)
48
+ execSync('pbcopy', {input: markdownInput});
49
+ // Verify no HTML flavor exists (or at least we want to simulate that state)
50
+ // Actually pbcopy only sets text flavor, so this is perfect.
51
+
52
+ // 2. Run markpaste
53
+ execSync(`"${CLIP_TOOL}"`, {encoding: 'utf8'});
54
+
55
+ // 3. Verify HTML flavor (should be rendered HTML)
56
+ const htmlData = execSync('osascript -e "get (the clipboard as «class HTML»)"', {encoding: 'utf8'}).trim();
57
+ assert.strictEqual(htmlData.includes('«data HTML'), true);
58
+
59
+ const match = htmlData.match(/«data HTML([0-9A-F]*)»/i);
60
+ const resultHtml = Buffer.from(match[1], 'hex').toString('utf8');
61
+ assert.strictEqual(resultHtml.toLowerCase().includes('<h1>md test</h1>'), true);
62
+ assert.strictEqual(resultHtml.toLowerCase().includes('<li>item 1</li>'), true);
63
+
64
+ // 4. Verify Plain Text flavor (should be original markdown)
65
+ const plainText = execSync('osascript -e "get (the clipboard as string)"', {encoding: 'utf8'}).trim();
66
+ assert.strictEqual(plainText, markdownInput);
67
+ });
68
+ } else {
69
+ test('markpaste cli: skipped on non-macOS', () => {
70
+ // Pass
71
+ });
72
+ }
@@ -1,12 +1,12 @@
1
- import { test } from 'node:test';
1
+ import {test} from 'node:test';
2
2
  import assert from 'node:assert';
3
- import { getConverter } from '../../src/converter.js';
3
+ import {getConverter} from '../../src/converter.js';
4
4
 
5
5
  test('pandoc: converter should convert HTML to Markdown', async () => {
6
6
  const converter = await getConverter('pandoc');
7
7
  const html = '<h1>Hello</h1>';
8
8
  const markdown = converter.convert(html);
9
-
9
+
10
10
  assert.strictEqual(markdown.includes('Hello'), true);
11
11
  // Pandoc usually adds its own flavor of Markdown
12
12
  assert.strictEqual(markdown.includes('#'), true);
@@ -1,6 +1,6 @@
1
- import { test, expect } from '@playwright/test';
1
+ import {test, expect} from '@playwright/test';
2
2
 
3
- test('should load the page without errors or 404s', async ({ page }) => {
3
+ test('should load the page without errors or 404s', async ({page}) => {
4
4
  const failedRequests: string[] = [];
5
5
  const consoleErrors: string[] = [];
6
6
 
@@ -39,14 +39,14 @@ test('should load the page without errors or 404s', async ({ page }) => {
39
39
  await expect(page.locator('h1')).toHaveText('MarkPaste');
40
40
  });
41
41
 
42
- test('should paste HTML and update all 2 markdown outputs', async ({ page }) => {
42
+ test('should paste HTML and update all 2 markdown outputs', async ({page}) => {
43
43
  await page.goto('http://127.0.0.1:8081/index.html');
44
44
 
45
45
  const inputArea = page.locator('#inputArea');
46
46
  const testHtml = '<h1>Test Title</h1><p>Hello <strong>world</strong></p>';
47
47
 
48
48
  // Simulate paste event
49
- await page.evaluate((html) => {
49
+ await page.evaluate(html => {
50
50
  const el = document.querySelector('#inputArea');
51
51
  if (!el) return;
52
52
  const dataTransfer = new DataTransfer();
@@ -77,7 +77,7 @@ test.describe('MarkPaste functionality', () => {
77
77
  // Uncheck "Clean HTML"
78
78
  await page.locator('#cleanHtmlToggle').uncheck();
79
79
  // Wait for update
80
- await page.waitForTimeout(100);
80
+ await page.waitForTimeout(100);
81
81
 
82
82
  expect(await htmlCode.textContent()).toContain('<div>');
83
83
  expect(await htmlCode.textContent()).not.toContain('<script>');
@@ -114,4 +114,31 @@ test.describe('MarkPaste functionality', () => {
114
114
  await expect(htmlCode).toContainText('<td');
115
115
  await expect(htmlCode).toContainText('<th');
116
116
  });
117
+
118
+ test('should handle pasting markdown and pipe it through', async ({page}) => {
119
+ const markdown = '# This is Markdown\n\n- List item 1\n- List item 2';
120
+
121
+ // Simulate paste event
122
+ await page.evaluate(text => {
123
+ const inputArea = document.getElementById('inputArea');
124
+ const dataTransfer = new DataTransfer();
125
+ dataTransfer.setData('text/plain', text);
126
+ const event = new ClipboardEvent('paste', {
127
+ clipboardData: dataTransfer,
128
+ bubbles: true,
129
+ cancelable: true,
130
+ });
131
+ inputArea.dispatchEvent(event);
132
+ }, markdown);
133
+
134
+ const outputCode = page.locator('#outputCodeTurndown');
135
+ await expect(outputCode).toHaveText(markdown);
136
+
137
+ const htmlCode = page.locator('#htmlCode');
138
+ // It should be rendered HTML in the preview
139
+ const htmlText = await htmlCode.innerText();
140
+ expect(htmlText).toContain('<h1>This is Markdown</h1>');
141
+ expect(htmlText).toContain('<ul>');
142
+ expect(htmlText).toContain('<li>List item 1</li>');
143
+ });
117
144
  });
@@ -29,19 +29,22 @@ declare global {
29
29
  on(name: string, fn: EventListenerOrEventListenerObject): void;
30
30
  }
31
31
 
32
-
33
32
  // idle detector
34
33
 
35
34
  interface IdleDetector {
36
- addEventListener(type: "change", listener: (this: IdleDetector, ev: { userState: "active" | "idle", screenState: "locked" | "unlocked" }) => unknown, options?: boolean | AddEventListenerOptions): void;
37
- start(options: { threshold: number; signal?: AbortSignal }): Promise<void>;
38
- screenState: "locked" | "unlocked";
39
- userState: "active" | "idle";
35
+ addEventListener(
36
+ type: 'change',
37
+ listener: (this: IdleDetector, ev: {userState: 'active' | 'idle'; screenState: 'locked' | 'unlocked'}) => unknown,
38
+ options?: boolean | AddEventListenerOptions
39
+ ): void;
40
+ start(options: {threshold: number; signal?: AbortSignal}): Promise<void>;
41
+ screenState: 'locked' | 'unlocked';
42
+ userState: 'active' | 'idle';
40
43
  }
41
44
 
42
45
  declare const IdleDetector: {
43
- new(): IdleDetector;
44
- requestPermission(): Promise<"granted" | "denied">;
46
+ new (): IdleDetector;
47
+ requestPermission(): Promise<'granted' | 'denied'>;
45
48
  };
46
49
  }
47
50
  /// <reference path="pandoc-wasm.d.ts" />
package/src/markpaste.js DELETED
@@ -1,26 +0,0 @@
1
- /**
2
- * markpaste.js
3
- * MarkPaste Library Entry Point
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} html The HTML 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
- * @returns {Promise<string>} The resulting Markdown string.
16
- */
17
- export async function convert(html, options = {}) {
18
- const { converter: converterName = 'turndown', clean = true } = options;
19
-
20
- const cleanedHtml = clean ? await cleanHTML(html) : await removeStyleAttributes(html);
21
- const converter = await getConverter(converterName);
22
-
23
- return converter.convert(cleanedHtml);
24
- }
25
-
26
- export { cleanHTML, removeStyleAttributes, getConverter };