You know that moment when you write a function you're genuinely proud of, and you want to share it on LinkedIn or Bluesky — but plain text just… doesn't do it justice?
I've been there every week.
I'm Kareem (FreeRave), founder of DotSuite — a suite of privacy-first Linux tools and VS Code extensions. DotShare is my VS Code extension for sharing content across LinkedIn, X, Bluesky, Reddit, Dev.to, Medium, Telegram, and more — all without leaving the editor.
Today I'm shipping v3.4.0, and the headline feature is CodeSnap: a code-to-image tool built entirely inside the extension's WebView, using nothing but HTML Canvas and Highlight.js.
No node-canvas. No sharp. No native binaries. Zero extra dependencies.
Let me walk you through how it works and why I built it this way.
The Problem: Code Screenshots in VS Code Are Painful
The existing solutions are either:
- External web apps (Carbon, Ray.so) — you copy code, tab out, paste, configure, download, come back, attach. Five steps too many.
- Other VS Code extensions (CodeSnap, Polacode) — great tools, but they don't integrate with a sharing workflow. You screenshot, then you still have to open your composer manually.
I wanted the whole loop inside one tool:
Select code → 📸 Snap → Pick platform → Composer opens with image attached
That's it. No context switching.
The Architecture Decision: Why HTML Canvas?
When I started building this, the "obvious" choice was node-canvas — the Node.js port of the HTML Canvas API. But I ran into three hard problems immediately:
1. Native binaries are a Marketplace nightmare.
node-canvas requires node-gyp and compiles native C++ addons. On VS Code Marketplace, extensions with native binaries are flagged, slow to install, and break on ARM Macs and certain Linux distros.
2. sharp is 10MB+ of compiled code.
For a feature that renders a static image, that's an absurd payload.
3. VS Code already ships a full browser engine (Electron).
The WebView is a Chromium tab. It has a GPU-accelerated Canvas API, a full DOM parser, and font rendering that matches what the user actually sees on screen. Why fight it?
So the decision was: render in the WebView, export PNG from there, and ship it back to the extension host.
The flow looks like this:
┌─────────────────────┐ loadCode ┌─────────────────────┐
│ Extension Host │ ──────────────────────▶│ WebView (Canvas) │
│ (Node.js) │ │ (Chromium) │
│ │◀── snapReady (base64) ─│ │
│ CodeSnapPanel.ts │ │ codesnap.html │
│ MediaService.ts │ │ hljs + Canvas API │
└─────────────────────┘ └─────────────────────┘
│
▼
Saves to disk
QuickPick: which platform?
Opens Composer with image attached
CodeSnapService: Reading the Editor
The first piece is pure Node.js — no VS Code UI involved yet. CodeSnapService.capture() reads the active editor and returns everything the renderer needs:
// src/services/CodeSnapService.ts
export interface CodeSnapData {
code: string;
language: string; // resolved to HL.js alias
fileName: string;
lineStart: number;
lineEnd: number;
hasSelection: boolean;
}
public static capture(): CodeSnapData | null {
const editor = vscode.window.activeTextEditor;
if (!editor) return null;
const doc = editor.document;
const selection = editor.selection;
const hasSelection = !selection.isEmpty;
let code = hasSelection
? doc.getText(selection)
: doc.getText();
// Tabs break canvas rendering — convert to spaces first
code = code.replace(/\t/g, ' ');
// Strip common leading indent so the image doesn't waste space
code = CodeSnapService._stripCommonIndent(code);
return {
code,
language: CodeSnapService._resolveLanguage(doc.languageId, doc.fileName),
fileName: path.basename(doc.fileName),
lineStart: hasSelection ? selection.start.line + 1 : 1,
lineEnd: hasSelection ? selection.end.line + 1 : doc.lineCount,
hasSelection,
};
}
Two details worth calling out:
Tab conversion — HTML Canvas has inconsistent \t rendering across platforms. Converting to 4 spaces before we even touch the canvas eliminates an entire class of alignment bugs.
Common indent stripping — if you select a deeply nested function, the raw text has 16 spaces of leading indent on every line. _stripCommonIndent finds the minimum indent across all non-empty lines and removes it. The rendered image uses the full canvas width instead of leaving most of it empty.
private static _stripCommonIndent(code: string): string {
const lines = code.split('\n');
const minIndent = Math.min(
...lines
.filter(l => l.trim().length > 0)
.map(l => l.match(/^(\s*)/)?.[1].length ?? 0)
);
if (minIndent === 0) return code;
return lines.map(l => l.slice(minIndent)).join('\n').trimEnd();
}
The Canvas Renderer
The canvas rendering runs entirely in the WebView. Here's the core loop — I'll walk through it section by section.
Step 1: Measure before you paint
// Measure text to calculate canvas dimensions
const tmp = document.createElement('canvas');
const tmpCtx = tmp.getContext('2d');
tmpCtx.font = `${fontSize}px 'JetBrains Mono', 'Fira Code', Consolas, monospace`;
const lines = data.code.split('\n');
const maxCodeW = Math.max(...lines.map(l => tmpCtx.measureText(l).width));
const innerW = Math.ceil(maxCodeW + lineNumWidth) + padding * 2;
const innerH = lines.length * LINE_H + padding * 2 + TITLE_H;
We use a throwaway canvas just to measure text width before the real canvas exists. This lets us size the output to exactly fit the content — no hardcoded 800px width.
Step 2: 2x resolution for Retina
const cv = document.createElement('canvas');
cv.width = canvasW * 2; // physical pixels
cv.height = canvasH * 2;
cv.style.width = canvasW + 'px'; // CSS pixels
cv.style.height = canvasH + 'px';
const ctx = cv.getContext('2d');
ctx.scale(2, 2); // scale the context — all our coordinates stay the same
The canvas is 2× the display size but we scale the context by 2× before drawing. Every coordinate we use is still in CSS pixels. The exported PNG is full Retina resolution.
Step 3: Syntax highlighting via HL.js
This is where the real trick lives. HL.js gives us highlighted HTML like:
<span class="hljs-keyword">const</span>
<span class="hljs-title function_">greet</span>
<span class="hljs-punctuation">(</span>
<span class="hljs-params">name</span>
<span class="hljs-punctuation">)</span>
We parse that HTML into a flat token list, then paint each token with its color:
function parseHljsHtml(html, defaultColor) {
const out = [];
const parser = new DOMParser();
const doc = parser.parseFromString(`<pre>${html}</pre>`, 'text/html');
flattenNode(doc.querySelector('pre'), defaultColor, out);
return out;
}
function flattenNode(node, inheritColor, out) {
if (node.nodeType === Node.TEXT_NODE) {
if (node.textContent) out.push({ text: node.textContent, color: inheritColor });
return;
}
if (node.nodeType === Node.ELEMENT_NODE) {
const cls = (node.className || '').trim();
const color = activePalette[cls] ?? inheritColor;
node.childNodes.forEach(c => flattenNode(c, color, out));
}
}
flattenNode recursively walks the HL.js DOM output. When it hits a text node, it records the current inherited color. When it hits a <span>, it looks up the class name in our palette and updates the color for that subtree.
Then we split by newlines to get per-line segment arrays, and paint:
lineSegs.forEach((segs, i) => {
const y = codeY + i * LINE_H;
// Line number (dim, right-aligned)
if (showLines) {
ctx.fillStyle = 'rgba(200,200,220,.22)';
ctx.textAlign = 'right';
ctx.fillText(String(data.lineStart + i), lineNumX, y);
ctx.textAlign = 'left';
}
// Colored tokens, left-to-right
let x = codeX;
for (const seg of segs) {
if (!seg.text) continue;
ctx.fillStyle = seg.color;
ctx.fillText(seg.text, x, y);
x += ctx.measureText(seg.text).width;
}
});
This is the part that makes the output look good. Each token is measured and positioned individually, so the colors align exactly with the text.
The Race Condition I Fixed
The tricky part wasn't the canvas rendering — it was the integration between CodeSnap and the Composer.
The original approach was setTimeout:
// ❌ Old approach — fragile
vscode.commands.executeCommand('dotshare.openFullWebview', 'post', { platform });
setTimeout(() => {
DotShareWebView.postMessage({ command: 'mediaAttached', mediaFiles: [{ ... }] });
}, 800); // hope 800ms is enough...
This breaks on slow machines. It breaks on first load when VS Code needs to compile the extension. It breaks when the Composer webview is loading a saved draft.
The fix is a proper handshake. The Composer fires webviewReady when it mounts:
// app.ts (Composer WebView)
function onReady() {
enableDragAndDrop(get<HTMLTextAreaElement>('post-text'));
enableDragAndDrop(get<HTMLTextAreaElement>('blog-body'));
send('webviewReady'); // ← "I'm alive, send me stuff"
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', onReady, { once: true });
} else {
onReady();
}
The extension host handles webviewReady in DotShareWebView:
// DotShareWebView.ts
if (data?.command === 'webviewReady') {
vscode.commands.executeCommand('dotshare._composerReady', panel);
return;
}
And _composerReady atomically consumes any pending snap:
// extension.ts
vscode.commands.registerCommand('dotshare._composerReady', (panel: vscode.WebviewPanel) => {
const pending = CodeSnapPanel.consumePendingSnap();
if (pending) {
panel.webview.postMessage({
command: 'mediaAttached',
mediaFiles: [{
mediaPath: pending.filePath,
mediaFilePath: pending.filePath,
fileName: pending.fileName,
fileSize: 0,
}],
});
}
})
consumePendingSnap() uses a FIFO queue — reads and clears atomically, no double-delivery:
// FIFO queue — thread-safe, handles rapid double-snap edge case
private _pendingSnaps: Array<{ filePath: string; fileName: string }> = [];
public static consumePendingSnap(): { filePath: string; fileName: string } | null {
return CodeSnapPanel._instance?._pendingSnaps.shift() ?? null;
}
No race condition. No magic number timeouts. The image attaches the instant the Composer is ready — whether that's 200ms or 3 seconds.
Offline-First: No CDN
One more architectural decision worth mentioning: the VS Code webview CSP blocks external CDN requests by default — and that's correct behavior. I vendor all HL.js assets locally:
media/webview/vendor/
highlight.min.js
styles/
atom-one-dark.min.css
github-dark.min.css
monokai.min.css
dracula.min.css
nord.min.css
vs2015.min.css
tokyo-night-dark.min.css
github.min.css
catppuccin-mocha.min.css
The _buildHtml() method in CodeSnapPanel resolves all of these to webview.asWebviewUri() paths and injects them as a JSON map that the WebView uses for theme switching:
const themeCssMap: Record<string, string> = {};
for (const t of themes) {
const localPath = vscode.Uri.joinPath(stylesDir, `${t}.min.css`);
try {
fs.accessSync(localPath.fsPath);
themeCssMap[t] = webview.asWebviewUri(localPath).toString();
} catch {
// Skip missing files gracefully — theme won't appear in selector
}
}
html = html.replace(/\{\{THEME_CSS_MAP\}\}/g,
JSON.stringify(themeCssMap).replace(/</g, '\\u003c').replace(/>/g, '\\u003e')
);
Works completely offline. No network requests. No CDN downtime issues.
The Result
Here's what the full workflow looks like:
- Select a function in your editor
- Right-click → DotShare: 📸 CodeSnap (or use the keyboard shortcut)
- The CodeSnap panel opens beside your editor with a live preview
- Adjust theme, font size, padding, line numbers, watermark — instant re-render
- Click 🚀 Share → QuickPick: which platform?
- The Composer opens with the image already attached
- Write your caption, hit send
Or — from inside the Composer — click 📸 Add CodeSnap to open the panel mid-composition.
9 themes ship out of the box, completely free: Atom One Dark, GitHub Dark, GitHub Light, Monokai, Dracula, Nord, VS2015, Tokyo Night, Catppuccin Mocha.
Install DotShare
The extension is free and open source.
VS Code Marketplace:
marketplace.visualstudio.com/items?itemName=FreeRave.dotshare
Open VSX (VSCodium / Windsurf / Cursor):
open-vsx.org/extension/freerave/dotshare
GitHub:
github.com/kareem2099/DotShare
What's Next
CodeSnap is v1 — there's plenty left to build:
- Custom fonts: let users point to their own monospace font
- Gradient backgrounds: mesh gradients instead of solid BG color
- Multiple files: side-by-side code panels in one image
- Animated GIF export: show code being written, line by line
If any of these sound useful — or if you hit a bug — open an issue on GitHub or drop a comment below.
And if you ship a post using CodeSnap, tag me. I want to see what you're building. 🚀
— Kareem (FreeRave), founder of DotSuite
Top comments (2)
i totally relate to that frustration of wanting to share code visually - it makes a big difference. your approach with DotShare sounds really innovative, especially with the zero dependencies. at Moonshift, we help developers get a full next.js + postgres + auth app deployed in about 7 minutes, and you keep the code on your github. if you're curious, i can offer a free run.
Thanks for the kind words about DotShare
I was actually curious and tried to check out Moonshift, but it looks like your origin server is currently down (getting a Cloudflare 522 timeout).
Let me know when it's back up and running, I'd love to take a look at that free run