Skip to main content

Command Palette

Search for a command to run...

DotShare v3.0 — Architecting a VS Code Publishing Suite From Scratch" subtitle: "How one config file, a decoupled executor, and two blog APIs turned a social poster into a full publishing platform

Updated
11 min read
F
Architect and Creator of the DotUniverse—a massive open-source ecosystem dedicated to revolutionizing the Developer Experience (DX). With a specialized portfolio featuring over 20+ tools including DotShare, DotCommand, DotScramble, and DotGhostBoard, I focus on building high-performance, secure, and streamlined workflows for modern engineers. My mission is to empower the developer community by crafting modular tools that solve real-world problems. On this blog, I share deep dives into software architecture, cybersecurity insights, AI integration, and the journey of scaling a comprehensive suite of developer tools from the ground up.

I want to talk about a specific kind of technical debt that doesn't show up on any lint report.

It's the kind where you have the same if (platform === 'x') check written in four different files. Where your workspace-switching logic lives half in HTML, half in JavaScript, and half in a backend handler that nobody agreed was the right place for it. Where adding a new platform means finding every one of those checks and hoping you didn't miss one.

That was DotShare before v3.0.

DotShare is a VS Code extension that lets you post dev updates to social platforms and blogs directly from your IDE. v1 and v2 handled the posting. v3.0 was the rewrite that made the architecture worth being proud of — and the version that finally added Dev.to and Medium as first-class publishing targets.

This is the story of three architectural decisions that made it possible.


The Context: Why v3.0 Needed a Redesign

The original mental model was simple: textarea → platform checkboxes → share. It worked fine for Twitter, LinkedIn, Bluesky. But Dev.to and Medium broke that model completely.

Blog platforms need structured input. A title. Tags. A canonical URL for cross-posting. A cover image (as a public URL, not a file). A draft/publish toggle. A series field. None of that fits in a single textarea designed for 280-character tweets.

The naive solution would have been to add more fields to the existing composer and hide them behind if (platform === 'devto'). I've done that kind of thing before. It always ends the same way: five releases later, the conditions are nested three levels deep and nobody remembers why any of them exist.

Instead, v3.0 redesigns around a single principle: the platform drives the UI, not the other way around.


Decision 1: One Config File Rules Everything

The core of the new architecture is platform-config.ts. Every piece of behavior that varies per platform lives here and only here.

// src/platforms/platform-config.ts

export type WorkspaceType = 'social' | 'blog';
export type AuthType = 'oauth' | 'apikey' | 'bearer' | 'bot';

export interface PlatformConfig {
  id: string;
  name: string;
  icon: string;
  workspaceType: WorkspaceType;
  maxChars: number | null;
  supportsThreads: boolean;
  supportsMedia: boolean;
  supportsScheduling: boolean;
  charCountMethod: 'standard' | 'twitter';
  authType: AuthType;
}

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',
  },
  bluesky: {
    id: 'bluesky',
    name: 'Bluesky',
    icon: '🦋',
    workspaceType: 'social',
    maxChars: 300,
    supportsThreads: true,
    supportsMedia: true,
    supportsScheduling: false,
    charCountMethod: 'standard',
    authType: 'apikey',
  },
  devto: {
    id: 'devto',
    name: 'Dev.to',
    icon: '👨‍💻',
    workspaceType: 'blog',
    maxChars: 100000,
    supportsThreads: false,
    supportsMedia: false,       // API accepts URLs only — no file uploads
    supportsScheduling: false,
    charCountMethod: 'standard',
    authType: 'apikey',
  },
  medium: {
    id: 'medium',
    name: 'Medium',
    icon: 'Ⓜ️',
    workspaceType: 'blog',
    maxChars: 100000,
    supportsThreads: false,
    supportsMedia: false,       // API accepts URLs only
    supportsScheduling: false,
    charCountMethod: 'standard',
    authType: 'bearer',
  },
};

The WebView frontend reads this config on every platform switch. One function. Zero platform-specific branches:

// media/webview/app.ts

function switchPlatform(platformId: string): void {
  const config = PLATFORM_CONFIGS[platformId];
  if (!config) return;

  activeCommandPlatform = platformId;

  // Workspace switching — 100% driven by config, zero hardcoded platform checks
  document.querySelectorAll<HTMLElement>('.workspace').forEach(w => {
    w.style.display = 'none';
  });
  const workspace = document.getElementById(`workspace-${config.workspaceType}`);
  if (workspace) workspace.style.display = 'flex';

  // Thread toggle — also config-driven
  const threadBtn = document.getElementById('btn-thread-mode');
  if (threadBtn) {
    threadBtn.style.display = config.supportsThreads ? 'inline-flex' : 'none';
  }

  // Media attach button
  const mediaBtn = document.getElementById('btn-attach-media');
  if (mediaBtn) {
    mediaBtn.style.display = config.supportsMedia ? 'inline-flex' : 'none';
  }

  updatePlatformHeader(config);
  updateCharCounter();
  updateShareBtn();
}

The same config drives the backend too. Thread validation in PostHandler used to be:

// ❌ Before — hardcoded and fragile
if (platform !== 'x' && platform !== 'bluesky') {
  throw new Error('Threads not supported on this platform');
}

Now it's:

// ✅ After — config-driven and future-proof
if (!PLATFORM_CONFIGS[platform]?.supportsThreads) {
  throw new Error(`Threads not supported on ${PLATFORM_CONFIGS[platform]?.name ?? platform}`);
}

Adding a new platform now requires exactly one change: a new entry in PLATFORM_CONFIGS. Everything downstream — UI, validation, feature flags — adapts automatically.


Decision 2: The PostExecutor — Separating Execution from Environment

Before v3.0, posting logic was woven directly into PostHandler.ts, which imported vscode.* throughout. This created a hard dependency: you could only call posting logic from within an active VS Code extension host.

That became a real problem when building the background scheduler. A scheduled post that fires at 9am has no active editor. Calling PostHandler methods meant pulling in all of VS Code just to make an HTTP request to an API.

PostExecutor is the clean break. It's a pure TypeScript class that knows about platforms and APIs, but nothing about VS Code:

// 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 res = 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 = res.url;
        }

        if (target.platform === 'medium') {
          const token = await this.credentials.getMediumToken();
          const res = await shareToMedium(token, {
            title:         post.title,
            content:       post.body,
            contentFormat: 'markdown',
            tags:          post.tags,
            canonicalUrl:  post.canonicalUrl,
            publishStatus: normalizeMediumPublishStatus(post.publishStatus),
          });
          url = res.url;
        }

        results.push({ platform: target.platform, success: true, url });
        callbacks.onSuccess?.(target.platform, url);

        await this.history.add({
          platform: target.platform,
          title:    post.title,
          url,
          success:  true,
          timestamp: new Date().toISOString(),
        });

      } 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.add({
          platform:  target.platform,
          title:     post.title,
          success:   false,
          error:     error.message,
          timestamp: new Date().toISOString(),
        });
      }
    }

    return results;
  }
}

PostHandler becomes a thin adapter — it translates VS Code WebView messages into executor calls:

// src/handlers/PostHandler.ts

case 'shareToBlogs': {
  await this.executor.executeBlogPost(
    {
      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,
    },
    (msg.platforms as string[]).map(p => ({ platform: p })),
    {
      onProgress: (p, m) => this.sendStatus(m, 'info'),
      onSuccess:  (p, u) => this.sendStatus(`Published to \({p}\){u ? ': ' + u : ''}`, 'success'),
      onError:    (p, e) => this.sendStatus(`\({p} failed: \){e.message}`, 'error'),
    }
  );
  this.webview.postMessage({ command: 'shareComplete' });
  break;
}

The scheduler uses the exact same PostExecutor instance — no duplication, no second implementation of platform API calls.


Decision 3: Load Your Active Markdown File

The feature that made the blog workspace feel native to VS Code rather than bolted on: clicking "Load Current File" reads your active .md editor, parses the YAML frontmatter, and pre-fills every form field.

The backend reads the active editor through the VS Code API:

// src/handlers/MessageHandler.ts

case 'loadActiveFile': {
  const editor = vscode.window.activeTextEditor;

  if (!editor) {
    this.sendStatus('No active editor open.', 'error');
    return;
  }
  if (editor.document.languageId !== 'markdown') {
    this.sendStatus('Active file must be a .md file.', 'warning');
    return;
  }

  const raw    = editor.document.getText();
  const parsed = parseFrontmatter(raw);

  this.webview.postMessage({
    command:     'activeFileLoaded',
    content:     parsed.body,
    frontmatter: parsed.data,
    fileName:    path.basename(editor.document.uri.fsPath, '.md'),
  });
  break;
}

The frontmatter parser handles the fields both Dev.to and Medium care about:

// src/utils/frontmatterParser.ts

const FM_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/;

export function parseFrontmatter(raw: string): ParsedDocument {
  const match = raw.match(FM_REGEX);
  if (!match) return { data: {}, body: raw };

  const data: FrontMatter = {};

  for (const line of match[1].split('\n')) {
    const i     = line.indexOf(':');
    if (i === -1) continue;
    const key   = line.slice(0, i).trim();
    const value = line.slice(i + 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':
        if (value.startsWith('[')) {
          data.tags = value
            .replace(/[\[\]]/g, '')
            .split(',')
            .map(t => t.trim())
            .filter(Boolean);
        }
        break;
    }
  }

  return { data, body: match[2] };
}

The API Gotchas Worth Documenting

Dev.to: The Silent Image Drop

Dev.to's /api/articles endpoint does not support file uploads. Pass a local file path as cover_image and the API silently accepts the request, publishes the article, and drops the image with no error or warning. I caught this by noticing published articles had no cover image despite the field being set.

Fix: validate before the request.

private sanitizeBlogMedia(article: DevToArticle): DevToArticle {
  if (article.cover_image && !article.cover_image.startsWith('http')) {
    logger.warn(
      '[Dev.to] Local cover image path skipped — only public HTTPS URLs accepted. ' +
      'Upload to Cloudinary, Imgur, or GitHub first.'
    );
    return { ...article, cover_image: undefined };
  }
  return article;
}

Medium: The published vs public Mismatch

Medium's publish status enum uses the string "public". YAML frontmatter conventionally uses published: true or publishStatus: published. Pass either of those strings to the Medium API and your post silently becomes a draft.

export function normalizeMediumPublishStatus(
  raw: string | boolean | undefined
): 'public' | 'draft' | 'unlisted' {
  if (raw === true || raw === 'published' || raw === 'public') return 'public';
  if (raw === 'unlisted') return 'unlisted';
  return 'draft';
}

Medium: Two Requests, Not One

Medium's API requires you to fetch the authenticated user's ID before posting. Every publish operation is two HTTP calls:

// Step 1 — get userId
const me = await axios.get('https://api.medium.com/v1/me', {
  headers: { Authorization: `Bearer ${token}` },
});
const userId = me.data.data.id;

// Step 2 — publish to that userId's feed
await axios.post(`https://api.medium.com/v1/users/${userId}/posts`, payload, {
  headers: { Authorization: `Bearer ${token}` },
});

Not a major issue, but worth knowing if you're building rate-limit handling — each Medium publish costs two requests, not one.


The CredentialProvider Refactor

Every credential getter used to have the same if (credentialsGetter) ... else ... block copy-pasted across every method. Eight methods, eight copies of the same pattern.

The resolve() helper collapses all of them:

export class CredentialProvider {
  private async resolve(key: string, errorMsg: string): Promise<string> {
    const value = this.credentialsGetter
      ? (await this.credentialsGetter())[key as keyof Credentials]
      : await this.secretStorage.get(`dotshare.${key}`);

    if (!value) throw new Error(errorMsg);
    return value as string;
  }

  getDevToApiKey()    = () => this.resolve('devto.apiKey',      'Dev.to API key not configured.');
  getMediumToken()    = () => this.resolve('medium.token',      'Medium token not configured.');
  getLinkedInToken()  = () => this.resolve('linkedin.token',    'LinkedIn token not configured.');
  getRedditSubreddit() = () => this.resolve('reddit.subreddit', 'Target subreddit not configured.');
}

Three Production Bugs, Briefly

The Twitter URL counter. Twitter counts every URL as exactly 23 characters via t.co wrapping, regardless of actual length. Math.min(url.length, 23) was wrong — a 9-character URL counted as 9, not 23. Replaced with the constant 23.

The hardcoded subreddit. subreddit: 'test' left in PostHandler.ts from development. Reddit posts were going to r/test in production. Found in code review the day before release.

Stale post content. handleShareToX() called historyService.getLastPost() instead of message.post. Editing a post and resharing sometimes sent the previous version. One-line fix, twenty minutes to find.


v3.0 in Numbers

v2.4 v3.0
Platforms 7 9
Blog targets 0 2
Lines in PostHandler ~800 ~320
New TypeScript types 14
Platform-specific if checks in frontend 11 0

That last row is the one I'm most proud of.


Install

# VS Code
code --install-extension FreeRave.dotshare

# VSCodium / Gitpod / Open VSX
open-vsx.org/extension/freerave/dotshare

GitHub: github.com/kareem2099/DotShare


v3.1 covers the toast notification engine and a media preview race condition that took an embarrassingly long time to diagnose. v3.2 covers multi-image support — up to 4 images per post with live thumbnail previews. Both are in this series.