A deep technical dive into DotShare v3.0 — the Publishing Suite update that added Dev.to & Medium integrations, a YAML frontmatter parser, platform-first navigation, and a unified PostExecutor architecture. All from inside VS Code.
I Built a VS Code Extension That Posts to Dev.to, Medium & 7 Social Platforms — Here's How v3.0 Works
TL;DR — DotShare v3.0 "The Publishing Suite" transforms the extension from a social poster into a full publishing platform. This post is a deep technical walkthrough of the architecture decisions, the blog API integrations, and the tricky edge cases that took the most time to solve.
🧠 The Problem I Was Solving
Every time I shipped a new feature, I'd write a commit message, then write a Dev.to draft, then rephrase it for LinkedIn, then trim it for Twitter, then reformat it for Medium. Four context switches. Four text editors. Thirty minutes of overhead before my actual audience saw anything.
I'm a VS Code developer. I live in this editor. So I built DotShare — an extension that lets me write once, then distribute everywhere. v1 and v2 handled social platforms. v3.0 adds Dev.to and Medium, and rearchitects the whole thing into something I'm finally proud of.
Let me walk you through exactly how I built it.
🗺️ The Big Picture: What Changed in v3.0
Before v3.0, DotShare had one mental model: a short post goes to social media. The UI reflected that — a single textarea, a platform grid, a share button.
v3.0 breaks that assumption. Now there are two distinct workflows:
┌──────────────────────────────────────────────────┐
│ DotShare v3.0 Workspace │
├──────────────────────┬───────────────────────────┤
│ SOCIAL WORKSPACE │ BLOG WORKSPACE │
│ │ │
│ • Single post │ • Title + Tags │
│ • Thread composer │ • Markdown body │
│ • 280–25K chars │ • Canonical URL │
│ • Image attach │ • Cover image URL │
│ │ • Draft/Publish toggle │
│ Platforms: │ │
│ X, LinkedIn, │ Platforms: │
│ Bluesky, Reddit, │ Dev.to, Medium │
│ Facebook, Discord, │ │
│ Telegram │ │
└──────────────────────┴───────────────────────────┘
The key architectural move was making platform selection drive the UI, not the other way around. Let me show you how that works.
🏗️ Architecture: platform-config.ts as Single Source of Truth
The old code had workspace logic scattered everywhere — HTML conditionals, JS checks, handler guards. I replaced all of it with one file:
// src/platforms/platform-config.ts
export type WorkspaceType = 'social' | 'blog' | 'thread';
export type AuthType = 'oauth' | 'apikey' | 'bearer' | 'bot';
export interface PlatformConfig {
id: string;
name: string;
icon: string;
workspaceType: WorkspaceType;
maxChars: number | null; // null = no limit
supportsThreads: boolean;
supportsMedia: boolean;
supportsScheduling: boolean;
charCountMethod: 'standard' | 'twitter';
authType: AuthType;
color: string;
}
export const PLATFORM_CONFIGS: Record<string, PlatformConfig> = {
x: {
id: 'x',
name: 'X (Twitter)',
icon: '𝕏',
workspaceType: 'social',
maxChars: 280,
supportsThreads: true,
supportsMedia: true,
supportsScheduling: true,
charCountMethod: 'twitter',
authType: 'oauth',
color: '#000000',
},
devto: {
id: 'devto',
name: 'Dev.to',
icon: '👨💻',
workspaceType: 'blog',
maxChars: 100000,
supportsThreads: false,
supportsMedia: false, // API doesn't support file uploads
supportsScheduling: false,
charCountMethod: 'standard',
authType: 'apikey',
color: '#0a0a0a',
},
medium: {
id: 'medium',
name: 'Medium',
icon: 'Ⓜ️',
workspaceType: 'blog',
maxChars: 100000,
supportsThreads: false,
supportsMedia: false,
supportsScheduling: false,
charCountMethod: 'standard',
authType: 'bearer',
color: '#00ab6c',
},
// ... linkedin, bluesky, reddit, etc.
};
Now when the user clicks a platform icon in the sidebar, one function handles everything:
// media/webview/app.ts
function switchPlatform(platformId: string): void {
const config = PLATFORM_CONFIGS[platformId];
if (!config) return;
activeCommandPlatform = platformId;
// Workspace switching — driven entirely by config
const workspace = config.workspaceType;
document.querySelectorAll('.workspace').forEach(el => {
(el as HTMLElement).style.display = 'none';
});
const target = document.getElementById(`workspace-${workspace}`);
if (target) target.style.display = 'flex';
// Update platform header
updatePlatformHeader(config);
// Refresh character counter with new limits
updateCharCounter();
updateShareBtn();
}
No more scattered if (platform === 'devto') checks in 12 different places.
📝 The Blog Publish Page: Loading Your Active .md File
This was the feature I was most excited to build. The idea: you're editing a CHANGELOG.md or a blog-post.md in VS Code. Hit a button. The publish form fills itself.
Backend: Reading the Active Editor
// src/handlers/MessageHandler.ts
case 'loadActiveFile': {
const editor = vscode.window.activeTextEditor;
if (!editor) {
this.sendStatus('No active editor found', 'error');
return;
}
const doc = editor.document;
if (doc.languageId !== 'markdown') {
this.sendStatus('Active file is not a Markdown file', 'warning');
return;
}
const content = doc.getText();
const filePath = doc.uri.fsPath;
const fileName = path.basename(filePath, '.md');
// Parse frontmatter and send back to WebView
const parsed = parseFrontmatter(content);
this.webview.postMessage({
command: 'activeFileLoaded',
content: parsed.body,
frontmatter: parsed.data,
fileName,
});
break;
}
The YAML Frontmatter Parser
Dev.to and Medium both use YAML frontmatter. I built a parser that extracts the fields I care about:
// src/utils/frontmatterParser.ts
export interface FrontMatter {
title?: string;
description?: string;
tags?: string[];
cover_image?: string;
canonical_url?: string;
series?: string;
published?: boolean | 'draft' | 'unlisted';
}
export interface ParsedDocument {
data: FrontMatter;
body: string;
}
const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/;
export function parseFrontmatter(raw: string): ParsedDocument {
const match = raw.match(FRONTMATTER_REGEX);
if (!match) {
return { data: {}, body: raw };
}
const yamlBlock = match[1];
const body = match[2];
// Minimal YAML parser — handles the fields we care about
const data: FrontMatter = {};
const lines = yamlBlock.split('\n');
for (const line of lines) {
const colonIdx = line.indexOf(':');
if (colonIdx === -1) continue;
const key = line.slice(0, colonIdx).trim();
const value = line.slice(colonIdx + 1).trim().replace(/^['"]|['"]$/g, '');
switch (key) {
case 'title':
data.title = value;
break;
case 'description':
data.description = value;
break;
case 'cover_image':
data.cover_image = value;
break;
case 'canonical_url':
data.canonical_url = value;
break;
case 'series':
data.series = value;
break;
case 'published':
data.published = value === 'true' ? true : value === 'false' ? false : value as 'draft' | 'unlisted';
break;
case 'tags': {
// Handle both "tags: [a, b]" and multi-line "tags:\n - a"
if (value.startsWith('[')) {
data.tags = value
.replace(/[\[\]]/g, '')
.split(',')
.map(t => t.trim())
.filter(Boolean);
}
break;
}
}
}
return { data, body };
}
When the WebView receives the activeFileLoaded message, it populates the form:
// media/webview/app.ts
case 'activeFileLoaded': {
const { content, frontmatter, fileName } = msg;
// Populate body
const bodyEditor = get<HTMLTextAreaElement>('blog-body');
if (bodyEditor) bodyEditor.value = content;
// Populate frontmatter fields if present
if (frontmatter.title) {
const titleEl = get<HTMLInputElement>('blog-title');
if (titleEl) titleEl.value = frontmatter.title;
}
if (frontmatter.tags?.length) {
frontmatter.tags.forEach((tag: string) => addTagChip(tag));
}
if (frontmatter.canonical_url) {
const canonicalEl = get<HTMLInputElement>('blog-canonical');
if (canonicalEl) canonicalEl.value = frontmatter.canonical_url;
}
if (frontmatter.cover_image) {
const coverEl = get<HTMLInputElement>('blog-cover');
if (coverEl) coverEl.value = frontmatter.cover_image;
}
toast(`Loaded: ${fileName}.md`, 'success');
break;
}
🔌 Dev.to API Integration
Dev.to's API is clean and well-documented. Here's the full integration:
// src/platforms/devto.ts
import axios from 'axios';
import { logger } from '../utils/logger';
export interface DevToArticle {
title: string;
body_markdown: string;
published: boolean;
tags?: string[]; // max 4 tags
description?: string;
cover_image?: string; // must be a public URL
canonical_url?: string;
series?: string;
}
export interface DevToResponse {
id: number;
url: string;
title: string;
published: boolean;
}
export async function shareToDevTo(
apiKey: string,
article: DevToArticle
): Promise<DevToResponse> {
// Dev.to enforces a 4-tag maximum
const sanitizedTags = (article.tags ?? [])
.slice(0, 4)
.map(t => t.toLowerCase().replace(/[^a-z0-9]/g, ''));
const payload = {
article: {
title: article.title,
body_markdown: article.body_markdown,
published: article.published,
tags: sanitizedTags,
description: article.description,
cover_image: article.cover_image ?? null,
canonical_url: article.canonical_url ?? null,
series: article.series ?? null,
},
};
try {
const response = await axios.post<{ article: DevToResponse }>(
'https://dev.to/api/articles',
payload,
{
headers: {
'api-key': apiKey,
'Content-Type': 'application/json',
},
}
);
logger.info(`[Dev.to] Published: ${response.data.article.url}`);
return response.data.article;
} catch (err: unknown) {
if (axios.isAxiosError(err)) {
const status = err.response?.status;
const detail = err.response?.data?.error ?? err.message;
if (status === 422) {
// Validation error — usually malformed tags or missing title
throw new Error(`Dev.to validation error: ${detail}`);
}
if (status === 401) {
throw new Error('Dev.to API key is invalid or expired');
}
}
throw err;
}
}
The Image Policy Problem
Dev.to's API does not support file uploads. If you try to POST a local file as a cover image, it just silently drops it. I spent two hours debugging this before reading the docs carefully.
My solution: detect local file paths and warn the user instead of silently failing:
// src/handlers/PostHandler.ts
private validateBlogMedia(article: DevToArticle, platform: 'devto' | 'medium'): DevToArticle {
const { cover_image } = article;
if (cover_image && !cover_image.startsWith('http')) {
// Local file path — not supported
logger.warn(
`[${platform}] Local cover image "${cover_image}" skipped. ` +
`${platform === 'devto' ? 'Dev.to' : 'Medium'} only accepts public URLs. ` +
`Consider uploading to Cloudinary, GitHub, or Imgur first.`
);
return { ...article, cover_image: undefined };
}
return article;
}
Ⓜ️ Medium API Integration
Medium's API is older and has a few quirks worth documenting.
// src/platforms/medium.ts
import axios from 'axios';
export type MediumPublishStatus = 'public' | 'draft' | 'unlisted';
export type MediumContentFormat = 'markdown' | 'html';
export interface MediumPost {
title: string;
contentFormat: MediumContentFormat;
content: string;
tags?: string[]; // max 5 tags
canonicalUrl?: string;
publishStatus?: MediumPublishStatus;
}
// Medium's API uses "published" but their enum value is "public" — this trips people up
export function normalizeMediumPublishStatus(
status: string | boolean | undefined
): MediumPublishStatus {
if (status === true || status === 'published' || status === 'public') {
return 'public';
}
if (status === 'unlisted') return 'unlisted';
return 'draft'; // safe default
}
export async function shareToMedium(
bearerToken: string,
post: MediumPost
): Promise<{ id: string; url: string }> {
// Step 1: get the authenticated user's ID
const userResp = await axios.get('https://api.medium.com/v1/me', {
headers: { Authorization: `Bearer ${bearerToken}` },
});
const userId: string = userResp.data.data.id;
const sanitizedTags = (post.tags ?? []).slice(0, 5);
const payload = {
title: post.title,
contentFormat: post.contentFormat,
content: post.content,
tags: sanitizedTags,
canonicalUrl: post.canonicalUrl,
publishStatus: normalizeMediumPublishStatus(post.publishStatus),
};
const response = await axios.post(
`https://api.medium.com/v1/users/${userId}/posts`,
payload,
{
headers: {
Authorization: `Bearer ${bearerToken}`,
'Content-Type': 'application/json',
},
}
);
return {
id: response.data.data.id,
url: response.data.data.url,
};
}
The published → public gotcha: Medium's publish status enum uses "public", not "published". YAML frontmatter typically has published: true. If you pass that string directly to the API, Medium silently defaults to draft. The normalizeMediumPublishStatus() function handles all the variants.
⚡ The PostExecutor: Decoupling from VS Code UI
Before v3.0, all posting logic lived inside PostHandler.ts, which was tightly coupled to the VS Code extension host. This made it impossible to use the same logic from the scheduler running in the background.
v3.0 introduces PostExecutor — a clean execution engine with no VS Code dependencies:
// src/services/PostExecutor.ts
export interface ExecutorCallbacks {
onProgress?: (platform: string, message: string) => void;
onSuccess?: (platform: string, url?: string) => void;
onError?: (platform: string, error: Error) => void;
}
export class PostExecutor {
constructor(
private readonly credentials: CredentialProvider,
private readonly history: HistoryService,
) {}
async executeBlogPost(
post: BlogPost,
targets: PublishTarget[],
callbacks: ExecutorCallbacks = {}
): Promise<PublishResult[]> {
const results: PublishResult[] = [];
for (const target of targets) {
callbacks.onProgress?.(target.platform, `Publishing to ${target.platform}...`);
try {
let url: string | undefined;
if (target.platform === 'devto') {
const apiKey = await this.credentials.getDevToApiKey();
const result = await shareToDevTo(apiKey, {
title: post.title,
body_markdown: post.body,
published: post.publishStatus !== 'draft',
tags: post.tags,
description: post.description,
cover_image: post.coverImage,
canonical_url: post.canonicalUrl,
series: post.series,
});
url = result.url;
} else if (target.platform === 'medium') {
const token = await this.credentials.getMediumToken();
const result = await shareToMedium(token, {
title: post.title,
contentFormat: 'markdown',
content: post.body,
tags: post.tags,
canonicalUrl: post.canonicalUrl,
publishStatus: normalizeMediumPublishStatus(post.publishStatus),
});
url = result.url;
}
results.push({ platform: target.platform, success: true, url });
callbacks.onSuccess?.(target.platform, url);
// Log to history
await this.history.addEntry({
platform: target.platform,
content: post.title,
timestamp: new Date().toISOString(),
success: true,
url,
});
} catch (err: unknown) {
const error = err instanceof Error ? err : new Error(String(err));
results.push({ platform: target.platform, success: false, error: error.message });
callbacks.onError?.(target.platform, error);
await this.history.addEntry({
platform: target.platform,
content: post.title,
timestamp: new Date().toISOString(),
success: false,
error: error.message,
});
}
}
return results;
}
}
Now PostHandler just wires VS Code messages to the executor:
// src/handlers/PostHandler.ts (simplified)
case 'shareToBlogs': {
const post: BlogPost = {
title: msg.title,
body: msg.body,
tags: msg.tags ?? [],
publishStatus: msg.publishStatus ?? 'draft',
canonicalUrl: msg.canonicalUrl,
coverImage: msg.coverImage,
description: msg.description,
series: msg.series,
};
const targets: PublishTarget[] = (msg.platforms as string[]).map(p => ({
platform: p,
}));
await this.executor.executeBlogPost(post, targets, {
onProgress: (platform, message) => {
this.sendStatus(message, 'info');
},
onSuccess: (platform, url) => {
this.sendStatus(`✅ Published to ${platform}${url ? ': ' + url : ''}`, 'success');
},
onError: (platform, error) => {
this.sendStatus(`❌ ${platform} failed: ${error.message}`, 'error');
},
});
this.webview.postMessage({ command: 'shareComplete' });
break;
}
🔑 CredentialProvider: The resolve() Refactor
Every credential-getter used to look like this:
// ❌ BEFORE — repeated in every method
async getDevToApiKey(): Promise<string> {
if (this.credentialsGetter) {
const creds = await this.credentialsGetter();
const key = creds.devtoApiKey;
if (!key) throw new Error('Dev.to API key not configured');
return key;
} else {
const key = await this.secretStorage.get('dotshare.devto.apiKey');
if (!key) throw new Error('Dev.to API key not configured');
return key;
}
}
The resolve() refactor eliminates the duplication:
// ✅ AFTER
export class CredentialProvider {
private async resolve(
secretKey: string,
errorMessage: string
): Promise<string> {
const value = this.credentialsGetter
? (await this.credentialsGetter())[secretKey as keyof Credentials]
: await this.secretStorage.get(`dotshare.${secretKey}`);
if (!value) throw new Error(errorMessage);
return value;
}
async getDevToApiKey(): Promise<string> {
return this.resolve('devto.apiKey', 'Dev.to API key not configured. Go to Settings → Dev.to.');
}
async getMediumToken(): Promise<string> {
return this.resolve('medium.token', 'Medium integration token not configured.');
}
async getRedditSubreddit(): Promise<string> {
return this.resolve('reddit.subreddit', 'Target subreddit not configured.');
}
}
🐛 The Bugs That Took the Most Time
1. The Twitter URL Count Bug
Twitter counts URLs as exactly 23 characters, regardless of length. My original implementation used Math.min(url.length, 23), which was wrong — a 10-character URL would count as 10, not 23.
// ❌ Wrong
const urlChars = Math.min(url.length, 23);
// ✅ Correct — Twitter always counts URLs as exactly 23 chars
const urlChars = 23;
One character of difference. Caused character counters to be wrong for posts with URLs.
2. Reddit's Hardcoded Subreddit
There was a subreddit: 'test' string buried in PostHandler.ts from early development. Reddit posts were going to r/test in production. Found it in a code review.
// ❌ Found this in production
const subreddit = 'test';
// ✅ Fixed
const subreddit = await this.credentials.getRedditSubreddit();
if (!subreddit) {
throw new Error('Configure your target subreddit in DotShare settings.');
}
3. PostHandler Reading Stale History Instead of the Current Message
handleShareToX() was calling historyService.getLastPost() instead of reading message.post. This meant editing a post and resharing would sometimes send the previous version.
// ❌ Reading from history
const content = await historyService.getLastPost();
// ✅ Reading from the actual incoming message
const content = message.post;
📊 v3.0 by the Numbers
| Metric | v2.4 | v3.0 | Change |
|---|---|---|---|
| Platforms supported | 7 | 9 | +2 |
Lines in PostHandler.ts
|
~800 | ~320 | −60% |
Files in src/
|
14 | 23 | +9 new |
| TypeScript errors | 0 | 0 | ✅ |
| ESLint violations | 0 | 0 | ✅ |
New types in types.ts
|
— | 14 | +14 |
🚀 What's Next: v3.1 & v3.2
v3.0 is the foundation. The next two releases focus on:
- v3.1 "The Polish Pass" — Toast notification engine, glassmorphism UI, character limit validation, and fixing a nasty media preview race condition
- v3.2 "The Media Expansion" — Multi-image support (up to 4 images), per-thumbnail previews, and JIT image compression
I'll be writing deep dives on both.
🔗 Links
- GitHub: github.com/kareem2099/DotShare
- VS Code Marketplace: https://marketplace.visualstudio.com/items?itemName=FreeRave.dotshare
- Open VSX (VSCodium / Gitpod): https://open-vsx.org/extension/freerave/dotshare
-
Install via CLI:
code --install-extension freerave.dotshare - Support the project: buymeacoffee.com/freerave
If you're building in public or maintaining an open source project, give DotShare a try. I'd love to hear how you use it — drop a comment below 👇
Built with TypeScript, VS Code Extension API, and a lot of coffee.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.