Building my first VS Code extension, from a web form to the Marketplace

I was minifying legacy WordPress theme files by pasting them into a browser tab. Here's how that turned into a published VS Code extension, and the tooling it took to get there.

  • vs code
  • extensions
  • tooling
  • typescript

Minification has never been something I thought about. Every project I work on already has it: Grunt, cssnano, whatever the build step happens to be. You write CSS, you run the task, a .min.css comes out the other end. The tooling is part of the project, so the problem is solved before you arrive.

Then I started working on the WordPress sites at Silver Assist, and several of the older themes had no such thing. No Gulpfile, no npm scripts, no build directory — in some cases the theme shipped only the distributed version, with the source long gone. There was nothing to run.

So I did what you do: I found a tool online. Toptal's developer tools have a CSS minifier and a JavaScript minifier, both of which take a paste and hand back a minified string. For a one-off tweak that's completely fine.

It stopped being a one-off. A redesign season turned "occasionally" into "several times a day", and the workflow was: open the file, select all, copy, switch to the browser, paste, wait, copy the result, switch back, select all, paste, save. Nine steps to remove some whitespace.

Why write one when extensions already exist

They do exist, and some of them are good. A few things pushed me to write my own anyway.

The narrow one: I wanted a single extension that did CSS and JS, minify in place or into a .min file, and nothing else. Most of what I found either did more than I wanted or bundled it into a larger formatting story.

The honest one: I was curious. I started reading the Extension API docs to figure out how hard it would be, got about three pages in, and wanted to know whether I could take something all the way to publication rather than leaving it as a local experiment. Building a tool I would personally use every day made it much easier to finish — there was a real user waiting for it, and it was me.

What the scaffolding actually gives you

npx --package yo --package generator-code -- yo code gets you a working extension in about a minute: a TypeScript project, a launch.json that opens a second VS Code window with your extension loaded, a webpack config, and a test runner. The generated vsc-extension-quickstart.md is worth reading once and then deleting.

Almost everything a user sees is declared in package.json, not in code:

"contributes": {
  "commands": [
    { "command": "extension.minify", "title": "%commands.extension.minify.title%" }
  ],
  "menus": {
    "editor/context": [
      {
        "command": "extension.minify",
        "when": "resourceLangId == css || resourceLangId == javascript",
        "group": "_CSS&JSMinifier"
      }
    ]
  }
}

That when clause is doing real work: it's why the menu item shows up on a CSS file and stays out of the way everywhere else. And the %…% syntax is VS Code's static localization — the string is resolved from package.nls.json, with package.nls.es.json picked up automatically for Spanish users. Adding a language is adding a file. I did Spanish on day two, mostly to see whether it worked.

The code itself was smaller than the manifest. The first working version registered two commands and called out to the same web tool I'd been pasting into by hand — Toptal documents a raw API endpoint, so the extension is a thin client over the thing I was already using:

const apiUrl =
  fileType === "css"
    ? "https://www.toptal.com/developers/cssminifier/api/raw"
    : "https://www.toptal.com/developers/javascript-minifier/api/raw";

const response = await fetch(apiUrl, {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({ input: text }),
});

Nine steps became a right-click. That was the entire goal.

The tools worth knowing before you start

ToolWhat it's for
yo code (generator-code)Scaffolds the project, launch config and test setup
@vscode/vscePackages the .vsix and publishes to the Marketplace
@vscode/test-cli + @vscode/test-electronDownloads a real VS Code build and runs your tests inside it
webpack (or esbuild)Bundles to a single dist/extension.js; keeps the package small
package.nls.jsonStatic localization for commands, menus and settings
.vscodeignoreKeeps demo GIFs and test fixtures out of the shipped package

The one that surprised me is the test runner. Extension tests are not unit tests against a mock — vscode-test downloads an actual VS Code, launches it with your extension loaded, and runs Mocha inside that instance. Your tests can open a real document, run a real command and read the buffer back. It's slow and it's the right trade: the failures it catches are the ones users would have hit.

On CI that means Linux needs a virtual display, which is two lines:

- name: Run tests
  uses: coactions/setup-xvfb@v1
  with:
    run: npm test

I ran that matrix on macOS, Ubuntu and Windows from the first workflow. It was cheap to set up and I assumed it meant the extension was covered on all three. That assumption is worth revisiting later — testing on three operating systems and shipping correctly to three operating systems turn out to be different claims — but for a pure-TypeScript extension calling an HTTP endpoint, it held.

Publishing

This is the part I had no picture of going in, and it's more bureaucratic than technical.

The Marketplace doesn't authenticate against GitHub. You create a publisher in Azure DevOps, generate a Personal Access Token scoped to Marketplace (Manage), and hand that token to vsce. The publisher ID goes in package.json and becomes permanent — mine is miguel-colmenares, and the extension's identity is miguel-colmenares.css-js-minifier forever.

Then vsce publish uploads the package. A few things I learned by getting them slightly wrong first:

  • Versions are immutable. You cannot republish 0.0.3 — any correction, however small, is a new number.
  • The README is the product page, and it has an iteration loop of its own. My first week ran 0.0.1 through 0.0.8 in six days, and a good share of those releases changed no code whatsoever: I was tuning the README and the demo GIFs, looking at how the extension page actually rendered inside VS Code, adjusting, and going again. One of those rounds was an image URL that looks entirely reasonable and serves nothing — github.com/miguelcolmenares/css-js-minifier/images/minify.gif, missing the /raw/<branch>/ segment — three commits over eleven minutes to make two GIFs appear. Budget time for the listing the way you'd budget it for a feature. It's the only part of the extension that every visitor sees, and most of them never get past it.
  • Keybindings collide. My first default chord fought with a macOS system shortcut, which I only found out by using it. Pick something obscure and let people rebind it.
  • galleryBanner, icon, keywords and categories are the entire first impression. They take ten minutes and they're the difference between being findable and not.

What building it actually taught me

A manifest is an interface. Most of what makes an extension feel native — where commands appear, when they appear, what they're called in your language — is declarative configuration, not code. I spent more time getting package.json right than writing the logic, and that was the correct ratio.

Scope is what gets it shipped. Two commands, two languages, one job. Every feature I didn't build is a feature I didn't have to test on three platforms or explain in the README. The version that shipped is the version I could finish.

Publishing is a skill of its own. The code was a weekend. The publisher account, the token, the packaging, the listing, the CI — that was the other half, and none of it is in the API docs you start with.

I picked a dependency I don't control. The minification itself happens on someone else's server, over the network, at their rate limit and their availability. Today it works, and it's the reason the extension ships almost no code of its own. It's still the single assumption the whole thing rests on, and it's the one I'd expect to have to revisit.


If you work with CSS or JS in VS Code, the extension is on the Marketplace as JS & CSS Minifier Tool and Compressor, and the source is at miguelcolmenares/css-js-minifier. It's MIT, the issue tracker is open, and if something breaks in your setup or you want it to do something it doesn't, open an issue — bug reports with a file that reproduces the problem are the most useful thing anyone can send me. Pull requests are welcome too.