How I went from a simple social media poster to a full publishing suite with Dev.to, Medium, thread composers, and auth health checks — all without leaving VS Code.
I've been building DotShare — a VS Code extension that lets you post to LinkedIn, X, Bluesky, Telegram, Reddit, Discord, and Facebook directly from your editor. After shipping v2.4 with one-click OAuth, I thought I was done for a while.
Then I started writing more long-form content. Every time I finished a markdown article in VS Code, I had to open a browser, go to Dev.to, copy-paste my content, fix the formatting, add tags, set the canonical URL... you know the drill.
So I rebuilt the whole thing.
What Changed in v3.0 — The Publishing Suite
The headline feature is blog publishing — but the real story is the architecture behind it.
1. Platform-First Navigation
The old UI had "workspace tabs" — you picked Threads, Social, or Blogs, then selected your platform. It was confusing and redundant.
In v3.0, I flipped it: click the platform icon, the workspace adapts automatically.
// platform-config.ts — single source of truth
export const PLATFORM_CONFIGS: Record<string, PlatformConfig> = {
x: {
name: 'X / Twitter',
icon: '𝕏',
maxChars: 280,
premiumMaxChars: 25000,
supportsThreads: true,
workspaceType: 'threads', // → Thread Composer
charCountMethod: 'twitter',
authType: 'oauth2',
},
linkedin: {
name: 'LinkedIn',
maxChars: 3000,
supportsThreads: false,
workspaceType: 'social', // → Social Composer
authType: 'oauth2',
},
devto: {
name: 'Dev.to',
maxChars: 100000,
workspaceType: 'blogs', // → Article Publisher
authType: 'api_key',
},
};
This file is shared between the extension host and the WebView — no VS Code imports, just pure TypeScript. When the user clicks the Dev.to icon, switchPlatform('devto') reads workspaceType: 'blogs' and shows the Article Publisher. One source of truth, zero hardcoded workspace routing.
2. Dev.to Integration
Dev.to has a clean REST API. You POST to /api/articles with your api-key header and you're done.
// devto.ts
export async function shareToDevTo(
apiKey: string,
articleData: {
text: string;
title?: string;
tags?: string[];
description?: string;
coverImage?: string;
published?: boolean;
canonicalUrl?: string;
series?: string;
}
): Promise<string> {
const { title, body } = extractTitleFromMarkdown(articleData.text);
const tags = articleData.tags?.length
? articleData.tags.slice(0, 4) // Dev.to max: 4 tags
: extractTags(articleData.text);
const payload = {
article: {
title: articleData.title || title,
body_markdown: body,
tags,
published: articleData.published ?? true,
...(articleData.coverImage && { main_image: articleData.coverImage }),
...(articleData.canonicalUrl && { canonical_url: articleData.canonicalUrl }),
...(articleData.series?.trim() && { series: articleData.series.trim() }),
},
};
const response = await axios.post(
'https://dev.to/api/articles',
payload,
{ headers: { 'api-key': apiKey, 'Content-Type': 'application/json' } }
);
return response.data.url;
}
One thing I learned the hard way: Dev.to does not support image file uploads via API. The dev-to-uploads.s3.amazonaws.com bucket you see in articles is only accessible through their web editor. The API only accepts public URLs. So if you're building an integration, skip the upload logic entirely and tell users to host images externally.
3. YAML Frontmatter Parser
When you click "Load Current File", DotShare reads the active .md file from VS Code's editor and parses the frontmatter:
// frontmatter-parser.ts
export function parseFrontMatter(content: string): {
hasFrontmatter: boolean;
frontmatter?: FrontMatter;
body: string;
} {
const FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/;
const match = content.match(FRONTMATTER_REGEX);
if (!match) return { hasFrontmatter: false, body: content };
const [, yamlContent, body] = match;
const frontmatter: FrontMatter = {};
for (const line of yamlContent.split('\n')) {
const colonIndex = line.indexOf(':');
if (colonIndex === -1) continue;
const key = line.slice(0, colonIndex).trim();
const value = line.slice(colonIndex + 1).trim();
switch (key) {
case 'title': frontmatter.title = value.replace(/^['"]|['"]$/g, ''); break;
case 'tags': frontmatter.tags = parseTags(value); break;
case 'canonical_url': frontmatter.canonical_url = value; break;
case 'description': frontmatter.description = value; break;
case 'cover_image': frontmatter.cover_image = value; break;
case 'series': frontmatter.series = value; break;
case 'published': frontmatter.published = value === 'true'; break;
}
}
return { hasFrontmatter: true, frontmatter, body: body.trim() };
}
The publish form auto-fills from whatever frontmatter exists. If your article already has title, tags, and canonical_url in the frontmatter, you open DotShare, click Load, and hit Publish. Done.
4. Thread Composer + X Premium
X and Bluesky both support threads. The thread composer lets you build them post-by-post, with a per-post character counter that respects each platform's rules.
For X, I added a Premium toggle that switches the limit from 280 to 25,000 characters:
// In app.ts (WebView side)
const premiumToggle = document.getElementById('x-premium-toggle') as HTMLInputElement;
premiumToggle.addEventListener('change', () => {
const isPremium = premiumToggle.checked;
const maxChars = isPremium
? PLATFORM_CONFIGS['x'].premiumMaxChars // 25000
: PLATFORM_CONFIGS['x'].maxChars; // 280
updateAllCharCounters('x', maxChars);
});
For Bluesky threads, the extension joins all posts with a manual separator (===) and the bluesky.ts adapter splits them back:
// bluesky.ts
function createThreadChunks(text: string, maxLength = 300): string[] {
// Manual separator from Thread Composer
const manualParts = text.split(/\n\n===\n\n/);
if (manualParts.length > 1) {
const chunks: string[] = [];
for (const part of manualParts) {
const trimmed = part.trim();
if (trimmed.length > maxLength) {
chunks.push(...splitByWords(trimmed, maxLength));
} else {
chunks.push(trimmed);
}
}
return chunks.map((chunk, i) => `${chunk} (${i + 1}/${chunks.length})`);
}
return splitByWords(text, maxLength);
}
5. Twitter Character Counting — A Subtle Bug
Twitter counts every URL as exactly 23 characters (their t.co proxy length), regardless of the actual URL length. I had a bug in the original implementation:
// ❌ Wrong — short URLs were counted as their actual length
text.replace(URL_REGEX, (url) => 'x'.repeat(Math.min(url.length, 23)))
// ✅ Correct — all URLs are always 23 chars
text.replace(URL_REGEX, 'x'.repeat(23))
Small bug, but it made the character counter off for short URLs like https://t.co/abc.
v3.0.1 — The Medium Situation
Shortly after shipping v3.0, I realized something: Medium has stopped issuing new integration tokens.
Their official response from help.medium.com:
"Medium will not be issuing any new integration tokens for our API and will not allow any new integrations. All existing tokens will continue to work."
The only workaround is to email yourfriends@medium.com and request access manually — they do respond within 24 hours and do activate the token section on your account. So the medium.ts integration is solid, but the onboarding requires this extra step.
I added a notice directly in the Settings card so users know before they go hunting for a token that doesn't exist in the UI:
<p class="platform-notice">
⚠️ Medium no longer issues new tokens automatically.
Email <strong>yourfriends@medium.com</strong> to request access.
They usually respond within 24 hours.
</p>
The Medium API itself works fine — it's just the token provisioning that requires the manual email step.
v3.1.0 — Reliability: Auth Server Health Checks
DotShare uses an OAuth server (dotshare-auth-server.vercel.app) for X, Reddit, and Facebook. The TokenManager handles auto-refresh of expiring tokens.
The problem: if the auth server goes down, the user just gets a generic error when they try to post. The connect button stays enabled, the user clicks it, nothing happens.
The fix is straightforward — check health before attempting any OAuth operation, cache the result, and disable the button with a clear message if the server is unreachable:
// TokenManager.ts
let _lastHealthCheck: { ok: boolean; timestamp: number } | null = null;
const HEALTH_CACHE_MS = 30 * 1000; // 30 seconds
static async checkHealth(): Promise<boolean> {
// Return cached result if still fresh
if (_lastHealthCheck && Date.now() - _lastHealthCheck.timestamp < HEALTH_CACHE_MS) {
return _lastHealthCheck.ok;
}
try {
await axios.get(`${AUTH_SERVER_URL}/health`, { timeout: 5000 });
_lastHealthCheck = { ok: true, timestamp: Date.now() };
return true;
} catch {
_lastHealthCheck = { ok: false, timestamp: Date.now() };
Logger.warn('TokenManager: auth server unreachable ❌');
return false;
}
}
The 30-second cache is important — without it, every share attempt on X would make an extra HTTP request just to check if the server is alive.
In PostHandler, the OAuth platforms check health before trying to refresh:
private async shareToXWithUpdate(post: PostData, postId: string | undefined): Promise<void> {
// Only check health if user is on OAuth (has a refresh token)
const hasOAuthToken = !!(await this.context.secrets.get('xRefreshToken'));
if (hasOAuthToken) {
const serverOk = await TokenManager.checkHealth();
if (!serverOk) {
// Tell the WebView to disable the button
this.view.webview.postMessage({
command: 'oauthServerDown',
platform: 'x',
});
throw new Error('Auth server is currently unavailable. Please try again later.');
}
}
const xAccessToken = await TokenManager.getValidToken('x')
|| await this.context.secrets.get('xAccessToken') || '';
if (!xAccessToken) {
throw new Error(
'X credentials lost (possible Keychain issue). Please reconnect X in Settings.'
);
}
await shareToX(xAccessToken, '', post);
this.sendSuccess('Successfully posted to X!');
}
And in app.ts on the WebView side:
case 'oauthServerDown': {
const btn = document.getElementById(`oauthConnect_${msg.platform}`) as HTMLButtonElement;
if (btn) {
btn.disabled = true;
btn.textContent = '⚠️ Server unavailable — try later';
}
break;
}
Simple, no fallback complexity, no "switch to manual mode" dialogs. Server is down → button is disabled → clear message. The user can still use the "Advanced: Enter token manually" section if they have their own token.
Keychain Wipeout on Linux
VS Code's context.secrets is backed by the OS keychain — gnome-keyring on GNOME, libsecret on other Linux setups. Sometimes after a system update or password change, the keychain loses sync and all stored secrets come back empty.
The original error for missing X credentials was:
Error sharing to X: Error: X credentials not configured
Not helpful. Is it not configured, or did the keychain wipe it? The new message:
X credentials lost (possible Keychain issue). Please reconnect X in Settings.
This tells the user exactly what might have happened and what to do about it. Small change, big difference in support requests.
What's Next
The roadmap has v3.2 as a scheduling engine overhaul — recurring posts, a queue manager UI, time zone support, and a calendar view of all scheduled posts. After that, v3.3 is the AI co-pilot phase: platform-specific rewrites, smart thread splitting, and blog-to-social post generation.
The extension is free and open source at
Download & Support:
If you write technical content and want to stop context-switching between VS Code and your browser, give it a try.
If you hit any issues or want to contribute, open an issue on GitHub. Feedback on the Dev.to integration especially — I want to make the publishing flow as smooth as possible.


Top comments (0)