Build a Morse Code Translator Extension for VS Code
Every developer has a side project that teaches them a platform. A Morse code translator extension for VS Code is a good one: it is small enough to finish in an evening, it touches the real extension APIs (commands, selections, webviews, status bar), and the domain — encoding text as dots and dashes — is simple enough that the platform, not the problem, is what you are learning.
In this article we will build an extension that encodes the selected text to Morse code, decodes Morse back to text, and shows a live signal panel with a visual dot-dash display. No dependencies beyond the VS Code API.
Scaffolding: the honest minimum
Install the generator once:
npm install -g yo generator-code yo code Choose "New Extension (TypeScript)" and accept the defaults. You get a package.json with a contributes section, a src/extension.ts, and a build script. The generated sample registers a "Hello World" command — replace it, do not build on top of it. The interesting part of an extension is never the scaffold; it is the manifest.
The encoder: a table, not a library
Morse encoding is a dictionary lookup, and the table belongs in your code, not in a dependency. Put it in src/morse.ts:
const MORSE_TABLE: Record<string, string> = { A: ".-", B: "-...", C: "-.-.", D: "-..", E: ".", F: "..-.", G: "--.", H: "....", I: "..", J: ".---", K: "-.-", L: ".-..", M: "--", N: "-.", O: "---", P: ".--.", Q: "--.-", R: ".-.", S: "...", T: "-", U: "..-", V: "...-", W: ".--", X: "-..-", Y: "-.--", Z: "--..", "0": "-----", "1": ".----", "2": "..---", "3": "...--", "4": "....-", "5": ".....", "6": "-....", "7": "--...", "8": "---..", "9": "----.", ".": ".-.-.-", ",": "--..--", "?": "..--..", }; export function encode(text: string): string { return text .toUpperCase() .split(" ") .map((word) => [...word] .map((ch) => MORSE_TABLE[ch] ?? "") .filter(Boolean) .join(" ") ) .join(" / "); } const REVERSE_TABLE = Object.fromEntries( Object.entries(MORSE_TABLE).map(([ch, code]) => [code, ch]) ); export function decode(code: string): string { return code .split(" / ") .map((word) => word .split(" ") .map((symbol) => REVERSE_TABLE[symbol] ?? "�") .join("") ) .join(" "); } Two design decisions worth noting. First, unknown characters are dropped on encode rather than throwing — an editor command should never crash on a stray @. Second, unknown sequences decode to the replacement character � instead of disappearing, so a mistimed signal stays visible in the output. A tool that silently eats bad input teaches its user nothing.
Registering commands
Commands are the extension's public surface. Declare them in package.json so they appear in the Command Palette, then wire them up in extension.ts:
"contributes": { "commands": [ { "command": "morse.encodeSelection", "title": "Morse: Encode Selection to Morse Code" }, { "command": "morse.decodeSelection", "title": "Morse: Decode Selection to Text" }, { "command": "morse.showSignalPanel", "title": "Morse: Show Signal Panel" } ] } import * as vscode from "vscode"; import { encode, decode } from "./morse"; export function activate(context: vscode.ExtensionContext) { const encodeCmd = vscode.commands.registerTextEditorCommand( "morse.encodeSelection", (editor, edit) => { for (const selection of editor.selections) { const text = editor.document.getText(selection); if (text) { edit.replace(selection, encode(text)); } } } ); const decodeCmd = vscode.commands.registerTextEditorCommand( "morse.decodeSelection", (editor, edit) => { for (const selection of editor.selections) { const code = editor.document.getText(selection); if (code) { edit.replace(selection, decode(code)); } } } ); context.subscriptions.push(encodeCmd, decodeCmd); } registerTextEditorCommand is the right API here: it hands you the editor and an edit builder, and VS Code batches all your replacements into a single undo step. Handling every selection in the loop means multi-cursor works for free — select three words with three cursors and all three get encoded. That is the kind of detail that makes an extension feel native instead of bolted on.
The signal panel: a webview that shows the code
Encoding text is useful; seeing it as a signal is what makes the extension fun. A webview panel renders the encoded output as animated dots and dashes, one element at a time, at a speed the user can set:
function showSignalPanel(context: vscode.ExtensionContext, code: string) { const panel = vscode.window.createWebviewPanel( "morseSignal", "Morse Signal", vscode.ViewColumn.Beside, { enableScripts: true } ); const elements = code.split("").map((ch) => { if (ch === ".") return `<span class="dit"></span>`; if (ch === "-") return `<span class="dah"></span>`; if (ch === " ") return `<span class="gap"></span>`; return `<span class="word-gap"></span>`; }); panel.webview.html = `<!DOCTYPE html> <html><head><style> body { font-family: sans-serif; padding: 16px; } .dit { display: inline-block; width: 12px; height: 12px; border-radius: 50%; background: #569cd6; margin: 4px; } .dah { display: inline-block; width: 36px; height: 12px; border-radius: 6px; background: #569cd6; margin: 4px; } .gap { display: inline-block; width: 12px; } .word-gap { display: inline-block; width: 32px; } .lit { background: #ffd700 !important; } </style></head><body> <h2>${code}</h2> <div id="signal">${elements.join("")}</div> <script> const items = document.querySelectorAll("#signal span"); let i = 0; setInterval(() => { items.forEach((el) => el.classList.remove("lit")); if (items[i]) items[i].classList.add("lit"); i = (i + 1) % items.length; }, 400); </script> </body></html>`; } The timing here is deliberately simple — a fixed 400 ms step — because the point is the visual, not a metronome. A real version would derive the step from a words-per-minute setting (1200 / WPM milliseconds per unit, the PARIS standard) and read it from vscode.workspace.getConfiguration("morse"). Add a morse.wpm setting to package.json under contributes.configuration and the panel becomes a genuine practice tool: encode a sentence, watch it play, and try to read it back before the highlight reaches the end.
Check your encoder's output against a free Morse code translator while you develop — it is the fastest way to catch a wrong table entry, and the table is the one place a typo silently corrupts everything downstream.
Testing without clicking around
Extension tests run inside a real VS Code instance, which is heavier than a unit test and worth it exactly once — for the table. Keep the pure logic (encode, decode) dependency-free and test it with plain Node:
// src/morse.test.ts — run with any test runner, no VS Code needed import { encode, decode } from "./morse"; test("round trip", () => { expect(decode(encode("HELLO WORLD"))).toBe("HELLO WORLD"); }); test("SOS is nine signals", () => { expect(encode("SOS")).toBe("... --- ..."); }); test("unknown characters are dropped, not fatal", () => { expect(encode("HI@")).toBe(".... .."); }); The round-trip property is the strongest test in this project: for any string of encodable characters, decoding the encoding must return the original. If that test passes, the table is right in both directions. The VS Code API surface — commands, webviews, selections — is thin enough to verify by hand in the Extension Development Host (F5), which is what it is for.
Packaging and publishing
Package with vsce:
npm install -g @vscode/vsce vsce package That produces a .vsix you can install locally with code --install-extension morse-0.0.1.vsix. To publish, create a publisher at marketplace.visualstudio.com, generate a Personal Access Token, and run vsce publish. Before you do, fill in the manifest fields people actually read: description, repository, license, and a README.md with a screenshot of the signal panel. Extensions with a screenshot get installed; extensions without one get skimmed past.
What this project teaches
Three ideas transfer to any VS Code extension you build next. First, keep the domain pure: morse.ts knows nothing about VS Code, so it is testable without launching an editor. Second, use the narrowest API that does the job — registerTextEditorCommand instead of a manual edit, createWebviewPanel instead of a custom view — because the narrow API is the one VS Code keeps stable. Third, make failure visible: dropped characters on encode, � on decode, and a single undo step for every command. An extension that fails quietly is worse than one that fails loudly, because the user blames their own code.
A 180-year-old code, a modern editor, and an evening's work — that is a good weekend project.
Morse Zone (morsezone.com) builds free Morse code tools and learning guides — a handy reference for checking your extension's encoder output against known-good text.