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
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
name: Publish to npm
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
publish:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
id-token: write
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- uses: pnpm/action-setup@v4
|
|
19
|
+
with:
|
|
20
|
+
version: 10.22.0
|
|
21
|
+
|
|
22
|
+
- uses: actions/setup-node@v4
|
|
23
|
+
with:
|
|
24
|
+
node-version: 22.14.0 # Minimum for Trusted Publishing
|
|
25
|
+
registry-url: 'https://registry.npmjs.org'
|
|
26
|
+
cache: 'pnpm'
|
|
27
|
+
|
|
28
|
+
- run: pnpm install --frozen-lockfile
|
|
29
|
+
- run: pnpm exec playwright install
|
|
30
|
+
|
|
31
|
+
- run: pnpm test
|
|
32
|
+
|
|
33
|
+
- name: Publish to npm
|
|
34
|
+
run: |
|
|
35
|
+
npm install -g npm@latest
|
|
36
|
+
npm publish --provenance --access public --registry https://registry.npmjs.org/
|
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/
|
|
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
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# MarkPaste
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/markpaste)
|
|
4
|
+
|
|
3
5
|
MarkPaste is an isomorphic tool that converts rich text to Markdown. It works seamlessly in both the **browser** as a web application and in **Node.js** as a library.
|
|
4
6
|
|
|
5
7
|
## Features
|
|
@@ -13,16 +15,18 @@ MarkPaste is an isomorphic tool that converts rich text to Markdown. It works se
|
|
|
13
15
|
## Usage
|
|
14
16
|
|
|
15
17
|
### Web Application
|
|
18
|
+
|
|
16
19
|
1. **Open the app:** Run locally or host the `index.html`.
|
|
17
20
|
2. **Paste:** Copy rich text from a webpage or document and paste it into the input area.
|
|
18
21
|
3. **Get Markdown:** View the generated Markdown from multiple converters simultaneously.
|
|
19
22
|
4. **Copy:** Click to copy the Markdown to your clipboard.
|
|
20
23
|
|
|
21
24
|
### Node.js Library
|
|
25
|
+
|
|
22
26
|
MarkPaste can be used programmatically in Node.js environments. It uses `linkedom` to provide a lightweight DOM implementation for the conversion logic.
|
|
23
27
|
|
|
24
28
|
```javascript
|
|
25
|
-
import {
|
|
29
|
+
import {convert} from './src/index.js';
|
|
26
30
|
|
|
27
31
|
const html = '<h1>Hello World</h1><p>This is <b>bold</b> text.</p>';
|
|
28
32
|
|
|
@@ -31,11 +35,11 @@ const markdown = await convert(html);
|
|
|
31
35
|
console.log(markdown);
|
|
32
36
|
|
|
33
37
|
// Using Pandoc
|
|
34
|
-
const pandocMarkdown = await convert(html, {
|
|
38
|
+
const pandocMarkdown = await convert(html, {converter: 'pandoc'});
|
|
35
39
|
console.log(pandocMarkdown);
|
|
36
40
|
|
|
37
41
|
// Disabling HTML cleaning
|
|
38
|
-
const rawMarkdown = await convert(html, {
|
|
42
|
+
const rawMarkdown = await convert(html, {clean: false});
|
|
39
43
|
```
|
|
40
44
|
|
|
41
45
|
## Development
|
|
@@ -43,6 +47,7 @@ const rawMarkdown = await convert(html, { clean: false });
|
|
|
43
47
|
To run MarkPaste locally:
|
|
44
48
|
|
|
45
49
|
1. **Clone & Install:**
|
|
50
|
+
|
|
46
51
|
```bash
|
|
47
52
|
git clone https://github.com/paulirish/markpaste.git
|
|
48
53
|
cd markpaste
|
|
@@ -56,11 +61,14 @@ To run MarkPaste locally:
|
|
|
56
61
|
Access the application at `http://localhost:7025`.
|
|
57
62
|
|
|
58
63
|
### Testing
|
|
64
|
+
|
|
59
65
|
The project uses a dual-testing strategy:
|
|
66
|
+
|
|
60
67
|
- **Node.js tests:** Unit tests for isomorphic modules (`pnpm test:node`).
|
|
61
68
|
- **Playwright tests:** End-to-end browser testing (`pnpm test:web`).
|
|
62
69
|
|
|
63
70
|
Run all tests with:
|
|
71
|
+
|
|
64
72
|
```bash
|
|
65
73
|
pnpm test
|
|
66
74
|
```
|
package/bin/markpaste
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
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, labelWidth = 0) {
|
|
25
|
+
const dimmedStart = '\x1b[2m';
|
|
26
|
+
const dimmedEnd = '\x1b[22m';
|
|
27
|
+
const columns = process.stdout.columns || 80;
|
|
28
|
+
// maxLength = columns - labelWidth - 4 (for " ...") - 1 (safety)
|
|
29
|
+
const maxLength = Math.max(10, columns - labelWidth - 5);
|
|
30
|
+
const preview = text.substring(0, maxLength).replace(/\n/g, '\\n');
|
|
31
|
+
return `${dimmedStart}${preview}${dimmedEnd}...`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Reads the HTML flavor from the macOS clipboard.
|
|
36
|
+
* Returns null if no HTML flavor is present.
|
|
37
|
+
*/
|
|
38
|
+
function readHtmlFromClipboard() {
|
|
39
|
+
try {
|
|
40
|
+
const script = 'try\n the clipboard as «class HTML»\non error\n return ""\nend try';
|
|
41
|
+
const hexData = execSync(`osascript -e '${script}'`, { encoding: 'utf8' }).trim();
|
|
42
|
+
const match = hexData.match(/«data HTML([0-9A-F]*)»/i);
|
|
43
|
+
if (match && match[1]) {
|
|
44
|
+
return Buffer.from(match[1], 'hex').toString('utf8');
|
|
45
|
+
}
|
|
46
|
+
} catch (e) {
|
|
47
|
+
// Silently fail to return null
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Reads the plain text flavor from the macOS clipboard.
|
|
54
|
+
*/
|
|
55
|
+
function readTextFromClipboard() {
|
|
56
|
+
return execSync('pbpaste', { encoding: 'utf8' });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Sets both the HTML and Plain Text flavors on the macOS clipboard.
|
|
61
|
+
*/
|
|
62
|
+
function setClipboard(html, markdown) {
|
|
63
|
+
const mdTmp = path.join(os.tmpdir(), `mp_md_${Date.now()}.txt`);
|
|
64
|
+
writeFileSync(mdTmp, markdown);
|
|
65
|
+
|
|
66
|
+
const htmlHex = Buffer.from(html, 'utf8').toString('hex');
|
|
67
|
+
const appleScript = `
|
|
68
|
+
set theText to (read (POSIX file "${mdTmp}") as «class utf8»)
|
|
69
|
+
set the clipboard to {«class HTML»:«data HTML${htmlHex}», text:theText}
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
const result = spawnSync('osascript', [], { input: appleScript, encoding: 'utf8' });
|
|
73
|
+
unlinkSync(mdTmp);
|
|
74
|
+
|
|
75
|
+
if (result.status !== 0) {
|
|
76
|
+
throw new Error(`osascript failed: ${result.stderr}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function main() {
|
|
81
|
+
try {
|
|
82
|
+
const rawHtml = readHtmlFromClipboard();
|
|
83
|
+
const hasHtmlFlavor = !!rawHtml;
|
|
84
|
+
const input = rawHtml || readTextFromClipboard();
|
|
85
|
+
|
|
86
|
+
if (!input || input.trim() === '') {
|
|
87
|
+
console.error('No content found in clipboard.');
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const isMarkdown = isProbablyMarkdown(input, hasHtmlFlavor);
|
|
92
|
+
|
|
93
|
+
console.log('Read from clipboard:');
|
|
94
|
+
const typeLabel = isMarkdown ? 'Markdown ' : 'HTML ';
|
|
95
|
+
const inputLabel = `${typeLabel}(${input.length} chars): `;
|
|
96
|
+
console.log(` ${inputLabel}${textToPreview(input, inputLabel.length + 2)}`);
|
|
97
|
+
|
|
98
|
+
let cleanedHtml;
|
|
99
|
+
let markdown;
|
|
100
|
+
|
|
101
|
+
if (isMarkdown) {
|
|
102
|
+
markdown = input;
|
|
103
|
+
cleanedHtml = await marked.parse(input);
|
|
104
|
+
} else {
|
|
105
|
+
cleanedHtml = await cleanHTML(input);
|
|
106
|
+
markdown = await convert(input, { clean: true, isMarkdown: false });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
setClipboard(cleanedHtml, markdown);
|
|
110
|
+
|
|
111
|
+
const mdLabel = `Markdown (${markdown.length} chars): `;
|
|
112
|
+
const htmlLabel = `HTML (${cleanedHtml.length} chars): `;
|
|
113
|
+
const labelWidth = Math.max(mdLabel.length, htmlLabel.length) + 2;
|
|
114
|
+
|
|
115
|
+
console.log('\nClipboard updated with:');
|
|
116
|
+
console.log(` ${mdLabel.padEnd(labelWidth - 2)}${textToPreview(markdown, labelWidth)}`);
|
|
117
|
+
console.log(` ${htmlLabel.padEnd(labelWidth - 2)}${textToPreview(cleanedHtml, labelWidth)}`);
|
|
118
|
+
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.error('Error during conversion:', err);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
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": "/
|
|
139
|
-
"turndown": "/
|
|
140
|
-
"turndown-plugin-gfm": "/
|
|
141
|
-
"@bjorn3/browser_wasi_shim": "
|
|
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,23 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "markpaste",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"description": "HTML and Markdown, perfected for pasting into documents, emails, and more. So isomorphic. lol.",
|
|
5
|
-
"main": "src/
|
|
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"
|
|
13
16
|
},
|
|
14
17
|
"packageManager": "pnpm@10.22.0",
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
+
"keywords": [
|
|
19
|
+
"markdown",
|
|
20
|
+
"html",
|
|
21
|
+
"clipboard",
|
|
22
|
+
"isomorphic"
|
|
23
|
+
],
|
|
24
|
+
"author": "Paul Irish <npm@paul.irish>",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/paulirish/markpaste"
|
|
18
28
|
},
|
|
19
|
-
"keywords": [],
|
|
20
|
-
"author": "",
|
|
21
29
|
"license": "Apache-2.0",
|
|
22
30
|
"type": "module",
|
|
23
31
|
"devDependencies": {
|
package/playwright.config.ts
CHANGED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { writeFileSync, unlinkSync, mkdirSync, existsSync } from 'node:fs';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import { getConverter } from '../src/converter.js';
|
|
8
|
+
import { cleanHTML } from '../src/cleaner.js';
|
|
9
|
+
|
|
10
|
+
const testCases = [
|
|
11
|
+
{
|
|
12
|
+
name: 'Basic text',
|
|
13
|
+
html: '<p>Hello <b>world</b> and <i>everyone</i>!</p>'
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: 'Headings',
|
|
17
|
+
html: '<h1>Main Heading</h1><h2>Sub Heading</h2><h3>Third level</h3>'
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'Lists',
|
|
21
|
+
html: '<ul><li>Item 1</li><li>Item 2</li></ul><ol><li>First</li><li>Second</li></ol>'
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'Links',
|
|
25
|
+
html: '<p>Check out <a href="https://example.com">this link</a>.</p>'
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'Code',
|
|
29
|
+
html: '<p>Use <code>console.log()</code> to debug.</p><pre><code>function hello() {\n console.log("world");\n}</code></pre>'
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'Tables',
|
|
33
|
+
html: `
|
|
34
|
+
<table>
|
|
35
|
+
<thead>
|
|
36
|
+
<tr><th>Col 1</th><th>Col 2</th></tr>
|
|
37
|
+
</thead>
|
|
38
|
+
<tbody>
|
|
39
|
+
<tr><td>Data 1</td><td>Data 2</td></tr>
|
|
40
|
+
</tbody>
|
|
41
|
+
</table>
|
|
42
|
+
`
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'Tricky Span',
|
|
46
|
+
html: `
|
|
47
|
+
<p>The<span> </span>
|
|
48
|
+
<code class="w3-codespan">debugger</code>
|
|
49
|
+
<span> </span>keyword stops the execution of JavaScript.</p>
|
|
50
|
+
`
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'Nested formatting',
|
|
54
|
+
html: '<p><b>Bold and <i>italic and <u>underlined</u></i></b></p>'
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'Images',
|
|
58
|
+
html: '<img src="https://example.com/image.png" alt="An image" title="Image title">'
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'Blockquotes',
|
|
62
|
+
html: '<blockquote><p>This is a quote.</p><footer>— Someone</footer></blockquote>'
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'Horizontal Rule',
|
|
66
|
+
html: '<p>Before</p><hr><p>After</p>'
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'Task Lists',
|
|
70
|
+
html: '<ul><li>[ ] Todo</li><li>[x] Done</li></ul>'
|
|
71
|
+
}
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
async function runComparison() {
|
|
75
|
+
const turndown = await getConverter('turndown');
|
|
76
|
+
const pandoc = await getConverter('pandoc');
|
|
77
|
+
|
|
78
|
+
console.log('Comparing Turndown vs Pandoc...\n');
|
|
79
|
+
|
|
80
|
+
for (const tc of testCases) {
|
|
81
|
+
console.log(`Test Case: ${tc.name}`);
|
|
82
|
+
|
|
83
|
+
const cleaned = await cleanHTML(tc.html);
|
|
84
|
+
const tOut = (await turndown.convert(cleaned)).trim();
|
|
85
|
+
const pOut = (await pandoc.convert(cleaned)).trim();
|
|
86
|
+
|
|
87
|
+
if (tOut === pOut) {
|
|
88
|
+
console.log(' ✅ Match');
|
|
89
|
+
} else {
|
|
90
|
+
console.log(' ❌ Mismatch - Diffing...');
|
|
91
|
+
|
|
92
|
+
const tmpDir = path.join(os.tmpdir(), 'markpaste-comp');
|
|
93
|
+
if (!existsSync(tmpDir)) mkdirSync(tmpDir);
|
|
94
|
+
|
|
95
|
+
const tFile = path.join(tmpDir, 'turndown.md');
|
|
96
|
+
const pFile = path.join(tmpDir, 'pandoc.md');
|
|
97
|
+
|
|
98
|
+
writeFileSync(tFile, tOut + '\n');
|
|
99
|
+
writeFileSync(pFile, pOut + '\n');
|
|
100
|
+
|
|
101
|
+
// Use git diff --no-index | delta
|
|
102
|
+
const diffCmd = `git --no-pager diff --no-index --color=always ${tFile} ${pFile} | delta`;
|
|
103
|
+
const result = spawnSync('sh', ['-c', diffCmd], { encoding: 'utf8', stdio: 'inherit' });
|
|
104
|
+
|
|
105
|
+
// cleanup
|
|
106
|
+
unlinkSync(tFile);
|
|
107
|
+
unlinkSync(pFile);
|
|
108
|
+
}
|
|
109
|
+
console.log('-'.repeat(40));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Cleanup pandoc worker if necessary
|
|
113
|
+
if (pandoc.dispose) pandoc.dispose();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
runComparison().catch(err => {
|
|
117
|
+
console.error(err);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
});
|