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