markpaste 0.0.2 → 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.
@@ -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/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # MarkPaste
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/markpaste.svg)](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
package/bin/markpaste CHANGED
@@ -21,10 +21,13 @@ import { marked } from 'marked';
21
21
  /**
22
22
  * Formats a string for preview in the logs (truncated, escaped, and dimmed).
23
23
  */
24
- function textToPreview(text, length = 100) {
24
+ function textToPreview(text, labelWidth = 0) {
25
25
  const dimmedStart = '\x1b[2m';
26
26
  const dimmedEnd = '\x1b[22m';
27
- const preview = text.substring(0, length).replace(/\n/g, '\\n');
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');
28
31
  return `${dimmedStart}${preview}${dimmedEnd}...`;
29
32
  }
30
33
 
@@ -85,16 +88,17 @@ async function main() {
85
88
  process.exit(1);
86
89
  }
87
90
 
88
- console.log(`Reading from clipboard... (${input.length} chars)`);
89
- console.log(` Preview: ${textToPreview(input)}`);
90
-
91
91
  const isMarkdown = isProbablyMarkdown(input, hasHtmlFlavor);
92
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
+
93
98
  let cleanedHtml;
94
99
  let markdown;
95
100
 
96
101
  if (isMarkdown) {
97
- console.log('✨ Markdown detected. Rendering to HTML...');
98
102
  markdown = input;
99
103
  cleanedHtml = await marked.parse(input);
100
104
  } else {
@@ -102,25 +106,15 @@ async function main() {
102
106
  markdown = await convert(input, { clean: true, isMarkdown: false });
103
107
  }
104
108
 
105
- console.log('Updating clipboard...');
106
109
  setClipboard(cleanedHtml, markdown);
107
110
 
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)}`);
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)}`);
124
118
 
125
119
  } catch (err) {
126
120
  console.error('Error during conversion:', err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "markpaste",
3
- "version": "0.0.2",
3
+ "version": "0.0.6",
4
4
  "description": "HTML and Markdown, perfected for pasting into documents, emails, and more. So isomorphic. lol.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -15,12 +15,17 @@
15
15
  "test:node": "node --test test/node/**/*.test.js"
16
16
  },
17
17
  "packageManager": "pnpm@10.22.0",
18
- "engines": {
19
- "npm": "Please use pnpm instead of NPM to install dependencies",
20
- "pnpm": "10.22.0"
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"
21
28
  },
22
- "keywords": [],
23
- "author": "",
24
29
  "license": "Apache-2.0",
25
30
  "type": "module",
26
31
  "devDependencies": {
@@ -17,8 +17,8 @@ export default defineConfig({
17
17
  },
18
18
  ],
19
19
  webServer: {
20
- command: 'npx http-server -p 8081',
21
- url: 'http://127.0.0.1:8081',
20
+ command: 'npx http-server -p 7025',
21
+ url: 'http://127.0.0.1:7025',
22
22
  reuseExistingServer: !process.env.CI,
23
23
  },
24
24
  });
@@ -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
+ });
package/src/cleaner.js CHANGED
@@ -106,6 +106,28 @@ function processNode(sourceNode, targetParent) {
106
106
  }
107
107
 
108
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
+
109
131
  // Special case: UL/OL without LI children (often a bug in clipboard content)
110
132
  // This tweak should only happen when this element is the FIRST element in the received DOM.
111
133
  if (tagName === 'UL' || tagName === 'OL') {
@@ -160,6 +182,16 @@ function processNode(sourceNode, targetParent) {
160
182
  export function removeStyleAttributes(html) {
161
183
  const doc = parseHTMLGlobal(html);
162
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
+
163
195
  const allElements = body.querySelectorAll('*');
164
196
  for (let i = 0; i < allElements.length; i++) {
165
197
  allElements[i].removeAttribute('style');
@@ -46,3 +46,24 @@ test('cleaner: handles leading OL/UL tags correctly', async () => {
46
46
  assert.strictEqual(cleaned.toLowerCase().includes('<h1'), true);
47
47
  assert.strictEqual(cleaned.toLowerCase().includes('manage python packages'), true);
48
48
  });
49
+
50
+ test('cleaner: should unwrap <b style="font-weight: normal"> and equivalents', async () => {
51
+ const cases = [
52
+ ['<b style="font-weight: normal">Not bold</b>', 'Not bold'],
53
+ ['<strong style="font-weight: normal">Not bold</strong>', 'Not bold'],
54
+ ['<b style="font-weight: 400">Not bold</b>', 'Not bold'],
55
+ ['<b style="FONT-WEIGHT: NORMAL">Not bold</b>', 'Not bold'],
56
+ ['<b style="font-weight: lighter">Not bold</b>', 'Not bold'],
57
+ ['<i style="font-style: normal">Not italic</i>', 'Not italic'],
58
+ ['<em style="font-style: normal">Not italic</em>', 'Not italic'],
59
+ ];
60
+
61
+ for (const [input, expected] of cases) {
62
+ const cleaned = await cleanHTML(input);
63
+ assert.strictEqual(cleaned, expected, `Failed for input: ${input}`);
64
+ }
65
+
66
+ // Should keep normal <b>
67
+ const bold = await cleanHTML('<b>Bold</b>');
68
+ assert.ok(bold.toUpperCase().includes('<B>BOLD</B>'));
69
+ });
@@ -27,7 +27,7 @@ test('should load the page without errors or 404s', async ({page}) => {
27
27
  consoleErrors.push(error.message);
28
28
  });
29
29
 
30
- await page.goto('http://127.0.0.1:8081/index.html');
30
+ await page.goto('http://127.0.0.1:7025/index.html');
31
31
 
32
32
  // Verify no network failures
33
33
  expect(failedRequests, `Found failed network requests: ${failedRequests.join(', ')}`).toHaveLength(0);
@@ -40,7 +40,7 @@ test('should load the page without errors or 404s', async ({page}) => {
40
40
  });
41
41
 
42
42
  test('should paste HTML and update all 2 markdown outputs', async ({page}) => {
43
- await page.goto('http://127.0.0.1:8081/index.html');
43
+ await page.goto('http://127.0.0.1:7025/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>';
@@ -2,7 +2,7 @@ import {test, expect} from '@playwright/test';
2
2
 
3
3
  test.describe('Cleaner functionality', () => {
4
4
  test.beforeEach(async ({page}) => {
5
- await page.goto('http://127.0.0.1:8081/index.html');
5
+ await page.goto('http://127.0.0.1:7025/index.html');
6
6
  });
7
7
 
8
8
  test('should remove MDN copy button and play links', async ({page}) => {
@@ -2,7 +2,7 @@ import {test, expect} from '@playwright/test';
2
2
 
3
3
  test.describe('MarkPaste functionality', () => {
4
4
  test.beforeEach(async ({page}) => {
5
- await page.goto('http://127.0.0.1:8081/index.html');
5
+ await page.goto('http://127.0.0.1:7025/index.html');
6
6
  });
7
7
 
8
8
  test('should convert basic rich text to markdown', async ({page}) => {
@@ -56,8 +56,7 @@ test.describe('MarkPaste functionality', () => {
56
56
  await expect(pandocOutput).toContainText('Hello World');
57
57
  });
58
58
 
59
- // SKIP this for now. needs a human to look into why its failing.
60
- test.skip('should toggle HTML cleaning', async ({page}) => {
59
+ test('should toggle HTML cleaning', async ({page}) => {
61
60
  const html = '<div><p>Hello</p><style>body{color:red;}</style><script>alert("xss")</script></div>';
62
61
 
63
62
  await page.evaluate(html => {
@@ -81,6 +80,7 @@ test.describe('MarkPaste functionality', () => {
81
80
 
82
81
  expect(await htmlCode.textContent()).toContain('<div>');
83
82
  expect(await htmlCode.textContent()).not.toContain('<script>');
83
+ expect(await htmlCode.textContent()).not.toContain('<style>');
84
84
  });
85
85
 
86
86
  test('should retain table structure', async ({page}) => {
package/TYPESCRIPT.md DELETED
@@ -1,109 +0,0 @@
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.
@@ -1,2 +0,0 @@
1
- overrides:
2
- markpaste: 'link:'