Skip to content

Commit 87c511e

Browse files
Add VS Code to Beehiiv conversion tool (#109)
### Motivation - Provide a small standalone HTML tool that converts Markdown pasted from VS Code into the rich HTML Beehiiv expects so it can be pasted directly into the Beehiiv editor. - Support common Markdown features such as links, bold, italics, inline code, and fenced code blocks while preserving language classes for syntax highlighting. - Offer a one-click `Copy to clipboard` action that writes both `text/plain` and `text/html` clipboard representations for seamless pasting into Beehiiv. ### Description - Add `vscode-to-beehiiv.html` implementing a UI with a Markdown input, Beehiiv HTML preview, and an output textarea, and add `vscode-to-beehiiv.docs.md` documenting the tool. - Convert Markdown to HTML with `marked.parse`, parse it with `DOMParser`, and assemble Beehiiv-ready blocks that include `data-id`, optional `data-pm-slice` on the first paragraph, and a `style` string (`BLOCK_STYLE`). - Normalize anchors to include `target="_blank"`, `rel="noopener noreferrer nofollow"`, and `class="link"`, and normalize code classes by replacing `language-py` with `language-python`. - Implement clipboard copy via `navigator.clipboard.write` with a `ClipboardItem` containing both `text/html` and `text/plain`, and handle paste events by reading `text/plain` from the clipboard into the editor. ### Testing - Started a local server with `python -m http.server 8000` to serve the new HTML file, which successfully began serving files. - Attempted to use Playwright to open the page and capture a screenshot, but the Playwright runs timed out and did not complete (failed). - No other automated tests were executed against the static HTML tool; clipboard interaction could not be validated due to browser automation timeouts. ------ [Codex Task](https://chatgpt.com/codex/tasks/task_e_6963c7d6dbe88325a94ebc5e37354539)
1 parent fe1b57d commit 87c511e

File tree

2 files changed

+280
-0
lines changed

2 files changed

+280
-0
lines changed

vscode-to-beehiiv.docs.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Convert Markdown copied from VS Code into Beehiiv-ready rich HTML, with a one-click clipboard copy.

vscode-to-beehiiv.html

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
4+
<head>
5+
<meta charset="UTF-8">
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
7+
<title>VS Code to Beehiiv</title>
8+
<link rel="stylesheet" href="styles.css">
9+
<style>
10+
body {
11+
max-width: 960px;
12+
margin: 0 auto;
13+
padding: clamp(1.5rem, 3vw, 2.5rem) clamp(1.25rem, 3vw, 2.75rem) clamp(3rem, 4vw, 4rem);
14+
}
15+
16+
header {
17+
margin-bottom: clamp(1.5rem, 3vw, 2.5rem);
18+
}
19+
20+
main {
21+
display: grid;
22+
gap: 1.5rem;
23+
}
24+
25+
.tool-card {
26+
padding: clamp(1.25rem, 3vw, 2rem);
27+
}
28+
29+
textarea {
30+
width: 100%;
31+
min-height: 240px;
32+
}
33+
34+
.output-wrapper {
35+
display: grid;
36+
gap: 1rem;
37+
}
38+
39+
.preview {
40+
border: 1px solid var(--ui-2);
41+
border-radius: 12px;
42+
padding: clamp(1rem, 2vw, 1.25rem);
43+
background: var(--bg);
44+
}
45+
46+
.button-row {
47+
display: flex;
48+
flex-wrap: wrap;
49+
gap: 0.75rem;
50+
align-items: center;
51+
}
52+
53+
.secondary {
54+
background: var(--bg);
55+
color: var(--tx);
56+
border: 1px solid var(--ui-2);
57+
}
58+
59+
.secondary:hover,
60+
.secondary:focus-visible {
61+
color: var(--accent);
62+
border-color: var(--accent);
63+
}
64+
65+
.status {
66+
color: var(--tx-3);
67+
min-height: 1.25rem;
68+
}
69+
70+
@media (max-width: 640px) {
71+
body {
72+
padding: 1.25rem 1rem 2.5rem;
73+
}
74+
}
75+
</style>
76+
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js" defer></script>
77+
</head>
78+
79+
<body>
80+
<header class="page-header">
81+
<a class="site-link" href="https://tools.mathspp.com/" aria-label="Back to tools.mathspp.com">← tools.mathspp.com</a>
82+
<h1>VS Code to Beehiiv</h1>
83+
<p class="lead">Paste Markdown from VS Code and copy Beehiiv-ready rich text.</p>
84+
</header>
85+
86+
<main>
87+
<section class="surface tool-card">
88+
<div class="form-group">
89+
<label for="markdown-input">Markdown content</label>
90+
<textarea id="markdown-input" name="markdown-input" placeholder="Paste Markdown from VS Code" spellcheck="false"></textarea>
91+
</div>
92+
<div class="button-row">
93+
<button id="copy-html" type="button" class="btn">Copy to clipboard</button>
94+
<button id="clear-input" type="button" class="btn secondary">Clear</button>
95+
</div>
96+
<p class="status" id="status" aria-live="polite"></p>
97+
</section>
98+
99+
<section class="surface tool-card" aria-live="polite">
100+
<h2>Beehiiv output</h2>
101+
<p class="lead">This preview uses the same HTML that will be copied.</p>
102+
<div class="output-wrapper">
103+
<div id="beehiiv-preview" class="preview"></div>
104+
<div class="form-group">
105+
<label for="beehiiv-html">HTML output</label>
106+
<textarea id="beehiiv-html" readonly></textarea>
107+
</div>
108+
</div>
109+
</section>
110+
</main>
111+
112+
<script>
113+
document.addEventListener('DOMContentLoaded', () => {
114+
const markdownInput = document.getElementById('markdown-input');
115+
const copyButton = document.getElementById('copy-html');
116+
const clearButton = document.getElementById('clear-input');
117+
const preview = document.getElementById('beehiiv-preview');
118+
const htmlOutput = document.getElementById('beehiiv-html');
119+
const status = document.getElementById('status');
120+
121+
const BLOCK_STYLE = 'font-style: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: auto; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration: none; caret-color: rgb(0, 0, 0); color: rgb(0, 0, 0);';
122+
123+
const updateStatus = (message) => {
124+
status.textContent = message;
125+
if (message) {
126+
setTimeout(() => {
127+
status.textContent = '';
128+
}, 3500);
129+
}
130+
};
131+
132+
const normalizeCodeLanguage = (codeElement) => {
133+
const className = codeElement.getAttribute('class') || '';
134+
if (className.includes('language-py')) {
135+
codeElement.setAttribute('class', className.replace('language-py', 'language-python'));
136+
}
137+
};
138+
139+
const convertMarkdownToBeehiiv = (markdown) => {
140+
if (!markdown.trim()) {
141+
return '';
142+
}
143+
144+
const rawHtml = marked.parse(markdown, { breaks: true, gfm: true });
145+
const parser = new DOMParser();
146+
const doc = parser.parseFromString(rawHtml, 'text/html');
147+
const blocks = [];
148+
149+
const nodes = Array.from(doc.body.childNodes).filter(node => {
150+
if (node.nodeType === Node.TEXT_NODE) {
151+
return node.textContent.trim() !== '';
152+
}
153+
return true;
154+
});
155+
156+
nodes.forEach((node, index) => {
157+
if (node.nodeType === Node.TEXT_NODE) {
158+
const paragraph = document.createElement('p');
159+
paragraph.textContent = node.textContent.trim();
160+
node = paragraph;
161+
}
162+
163+
if (node.nodeType !== Node.ELEMENT_NODE) {
164+
return;
165+
}
166+
167+
if (node.tagName === 'P') {
168+
const paragraph = document.createElement('p');
169+
paragraph.setAttribute('data-id', crypto.randomUUID());
170+
if (index === 0) {
171+
paragraph.setAttribute('data-pm-slice', '1 1 []');
172+
}
173+
paragraph.setAttribute('style', BLOCK_STYLE);
174+
paragraph.innerHTML = node.innerHTML;
175+
176+
paragraph.querySelectorAll('a').forEach(anchor => {
177+
anchor.setAttribute('target', '_blank');
178+
anchor.setAttribute('rel', 'noopener noreferrer nofollow');
179+
anchor.setAttribute('class', 'link');
180+
});
181+
182+
paragraph.querySelectorAll('code').forEach(code => {
183+
normalizeCodeLanguage(code);
184+
});
185+
186+
blocks.push(paragraph.outerHTML);
187+
return;
188+
}
189+
190+
if (node.tagName === 'PRE') {
191+
const pre = document.createElement('pre');
192+
pre.setAttribute('data-id', crypto.randomUUID());
193+
pre.setAttribute('style', BLOCK_STYLE.replace('white-space: normal;', ''));
194+
pre.innerHTML = node.innerHTML;
195+
196+
pre.querySelectorAll('code').forEach(code => {
197+
normalizeCodeLanguage(code);
198+
});
199+
200+
blocks.push(pre.outerHTML);
201+
return;
202+
}
203+
204+
if (node.tagName === 'UL' || node.tagName === 'OL') {
205+
const listParagraph = document.createElement('p');
206+
listParagraph.setAttribute('data-id', crypto.randomUUID());
207+
listParagraph.setAttribute('style', BLOCK_STYLE);
208+
listParagraph.innerHTML = node.outerHTML;
209+
blocks.push(listParagraph.outerHTML);
210+
return;
211+
}
212+
213+
if (node.tagName.startsWith('H')) {
214+
const headingParagraph = document.createElement('p');
215+
headingParagraph.setAttribute('data-id', crypto.randomUUID());
216+
headingParagraph.setAttribute('style', BLOCK_STYLE);
217+
headingParagraph.innerHTML = `<strong>${node.textContent}</strong>`;
218+
blocks.push(headingParagraph.outerHTML);
219+
}
220+
});
221+
222+
return `<head><meta charset="UTF-8"></head>${blocks.join('')}`;
223+
};
224+
225+
const renderOutput = () => {
226+
const markdown = markdownInput.value;
227+
const html = convertMarkdownToBeehiiv(markdown);
228+
htmlOutput.value = html;
229+
preview.innerHTML = html.replace(/<head>[\s\S]*?<\/head>/, '') || '<p><em>No content yet.</em></p>';
230+
};
231+
232+
markdownInput.addEventListener('paste', (event) => {
233+
const text = event.clipboardData?.getData('text/plain');
234+
if (text !== undefined) {
235+
event.preventDefault();
236+
markdownInput.value = text;
237+
renderOutput();
238+
}
239+
});
240+
241+
markdownInput.addEventListener('input', renderOutput);
242+
243+
copyButton.addEventListener('click', async () => {
244+
const markdown = markdownInput.value;
245+
const html = convertMarkdownToBeehiiv(markdown);
246+
247+
if (!html) {
248+
updateStatus('Add some Markdown before copying.');
249+
return;
250+
}
251+
252+
try {
253+
await navigator.clipboard.write([
254+
new ClipboardItem({
255+
'text/html': new Blob([html], { type: 'text/html' }),
256+
'text/plain': new Blob([markdown], { type: 'text/plain' })
257+
})
258+
]);
259+
updateStatus('Beehiiv HTML copied to clipboard.');
260+
} catch (error) {
261+
updateStatus('Clipboard access failed.');
262+
}
263+
});
264+
265+
clearButton.addEventListener('click', () => {
266+
markdownInput.value = '';
267+
renderOutput();
268+
});
269+
270+
renderOutput();
271+
});
272+
</script>
273+
274+
<footer class="page-footer">
275+
<p>Built with ❤️, 🤖, and 🐍, by <a href="https://mathspp.com/">Rodrigo Girão Serrão</a></p>
276+
</footer>
277+
</body>
278+
279+
</html>

0 commit comments

Comments
 (0)