Series Navigation
– Part 1: From Zero to Your First VS Code Theme
– Part 2: Stop Hand-Editing JSON: Engineering Your Theme with YAML (this article)
– Part 3: Light, Dark, and Beyond: Building Multi‑theme Variants with Gravity Compensation (coming soon)
– Part 4: From Theme to Design System: Visual Contracts and Brand Ecosystems (coming soon)
In Part 1, you learned how to create and publish your first VS Code theme from scratch. But as your theme grows, you probably run into these problems:
- A single JSON file becomes thousands of lines long; changing one color means hunting across the file and risking mistakes.
- Adding syntax highlighting for different languages means piling rules into one huge
tokenColorsarray—hard to read, even harder to maintain. - Wanting a light version? You have to duplicate the whole file and manually tweak hundreds of color values.
It’s time to bring engineering to your theme. This guide will show you how to refactor your monolithic JSON theme into a modular, maintainable project using YAML and a build script. You’ll end up with a clean, extensible source structure that makes future updates a breeze.
📦 Prerequisites
Make sure you have Node.js and npm (or pnpm) installed. Then install the build dependency:
npm install --save-dev js-yaml
⚠️ Note:
js-yamlmust be installed as a dev dependency—it’s only used during development, not shipped with the final theme.
📁 Directory Structure
We’ll put source files under src/, the build script in scripts/, and the generated JSON in themes/. A recommended layout:
your-theme/
├── src/
│ ├── core/
│ │ └── colors.yaml # color variables (primary, background, etc.)
│ ├── languages/ # syntax rules per language
│ │ ├── base.yaml # common rules for all languages
│ │ ├── javascript.yaml
│ │ ├── typescript.yaml
│ │ ├── python.yaml
│ │ ├── html.yaml
│ │ ├── css.yaml
│ │ └── ... (more languages)
│ ├── special/ # special rules (e.g., Better Comments)
│ │ └── better-comments.yaml
│ ├── workbench.yaml # UI colors (the `colors` object)
│ └── semantic.yaml # semantic highlighting rules
├── scripts/
│ └── build.js # build script
├── themes/
│ └── your-theme.json # final generated JSON (rename to match your theme)
├── package.json
└── .vscodeignore
🎨 Step 1: Extract Color Variables
Open your original theme JSON. Identify all color values that appear in the colors object and repeated colors in tokenColors. Turn them into variables. Create src/core/colors.yaml:
# Core color variables
primary: "#3b82f6" # main blue
success: "#34d399" # success green
warning: "#fbbf24" # warning yellow
error: "#f87171" # error red
highlight: "#7dd3fc" # glowing blue
bg: "#0f172a" # background
bgMuted: "#1e293b" # secondary background
text: "#e2e8f0" # foreground
textMuted: "#94a3b8" # muted text
border: "#2d3748" # borders
# ... more variables
Important rules:
- Use camelCase or lowercase with underscores (e.g.,
primary,bg_muted). The regex in the build script expects variable names consisting of letters, numbers, and underscores. -
Do NOT include opacity in variable definitions. Instead, apply opacity when referencing the variable, like
${primary}20. The build script will handle that. - Make sure every variable used elsewhere is defined here, otherwise the build will warn and leave
${var}unchanged, causing invalid colors.
✂️ Step 2: Split Syntax Rules
Break the tokenColors array into separate YAML files per language. Start with src/languages/base.yaml for rules common to all languages:
# Common rules
tokenColors:
- name: Comment
scope: ["comment", "punctuation.definition.comment"]
settings:
fontStyle: "italic"
foreground: "${comment}"
- name: Keyword
scope: ["keyword", "storage.type", "storage.modifier"]
settings:
foreground: "${primary}"
fontStyle: "bold"
- name: String
scope: ["string", "string.quoted.single", "string.quoted.double"]
settings:
foreground: "${success}"
# ... more common rules
For language-specific rules, e.g., src/languages/javascript.yaml:
# JavaScript specific rules
tokenColors:
- name: JSX Tag
scope: ["entity.name.tag.jsx", "meta.tag.jsx"]
settings:
foreground: "${function}"
Do the same for other languages. If a language has no special rules yet, you can still create an empty array to keep the file present (or skip it – the script only includes files listed in the merge order).
🧩 Step 3: Split UI Colors and Semantic Rules
Move the entire colors object to src/workbench.yaml and replace color values with variable references:
# Editor UI colors
editor.background: "${bg}"
editor.foreground: "${text}"
titleBar.activeBackground: "${bg}"
titleBar.activeForeground: "${text}"
statusBar.background: "${bg}"
statusBar.foreground: "${textMuted}"
# ... all UI keys
Move semanticTokenColors to src/semantic.yaml:
# Semantic highlighting rules
variable: "${variable}"
function: "${function}"
class: "${warning}"
"*.decorator":
foreground: "${purple}"
fontStyle: "italic"
# ... other semantic rules
🔍 Note: In YAML, keys with special characters (like *.decorator) must be quoted.
🔨 Step 4: Write the Build Script
Create scripts/build.js. This script will:
- Load
colors.yamlto get the variable dictionary. - Load
workbench.yaml,semantic.yaml, and all language rules. - Recursively replace
${variable}with actual color values (and handle opacity suffixes). - Merge
tokenColorsin a specified order. - Output the final JSON to
themes/.
Here’s a complete example (replace your-theme with your actual theme name):
const fs = require("fs");
const yaml = require("js-yaml");
const path = require("path");
const ROOT_DIR = path.resolve(__dirname, "..");
// Paths
const COLOR_FILE = path.join(ROOT_DIR, "src", "core", "colors.yaml");
const WORKBENCH_FILE = path.join(ROOT_DIR, "src", "workbench.yaml");
const SEMANTIC_FILE = path.join(ROOT_DIR, "src", "semantic.yaml");
const LANG_DIR = path.join(ROOT_DIR, "src", "languages");
const SPECIAL_DIR = path.join(ROOT_DIR, "src", "special");
const OUTPUT_FILE = path.join(ROOT_DIR, "themes", "your-theme.json"); // rename
// Load color variables
const colors = yaml.load(fs.readFileSync(COLOR_FILE, "utf8"));
// Recursive variable replacement (supports opacity suffix)
function replaceVariables(obj) {
if (typeof obj === "string") {
return obj.replace(/\$\{([a-zA-Z0-9_-]+)\}([0-9a-fA-F]{2})?/g, (match, key, alpha) => {
if (colors[key] !== undefined) {
return colors[key] + (alpha || "");
}
console.warn(`Warning: variable "${key}" not defined, keeping placeholder`);
return match;
});
}
if (Array.isArray(obj)) {
return obj.map(replaceVariables);
}
if (obj && typeof obj === "object") {
const result = {};
for (const [k, v] of Object.entries(obj)) {
result[k] = replaceVariables(v);
}
return result;
}
return obj;
}
// Load and replace UI colors
const workbench = yaml.load(fs.readFileSync(WORKBENCH_FILE, "utf8"));
const uiColors = replaceVariables(workbench);
// Load and replace semantic rules
const semantic = yaml.load(fs.readFileSync(SEMANTIC_FILE, "utf8"));
const semanticColors = replaceVariables(semantic);
// Language rule merge order (later files override earlier ones)
const order = [
"base.yaml",
"html.yaml",
"css.yaml",
"javascript.yaml",
"typescript.yaml",
"vue.yaml",
"json.yaml",
"markdown.yaml",
"python.yaml",
"jsdoc.yaml",
// add more as needed
];
let tokenColors = [];
order.forEach((file) => {
const filePath = path.join(LANG_DIR, file);
if (fs.existsSync(filePath)) {
try {
const langRules = yaml.load(fs.readFileSync(filePath, "utf8"));
if (langRules?.tokenColors) {
tokenColors = tokenColors.concat(langRules.tokenColors);
}
} catch (err) {
console.error(`Failed to parse ${file}:`, err.message);
}
} else {
console.warn(`File ${file} not found, skipping`);
}
});
// Load special rules (e.g., Better Comments)
const specialFile = path.join(SPECIAL_DIR, "better-comments.yaml");
if (fs.existsSync(specialFile)) {
const specialRules = yaml.load(fs.readFileSync(specialFile, "utf8"));
if (specialRules?.tokenColors) {
tokenColors = tokenColors.concat(specialRules.tokenColors);
console.log("✅ Loaded Better Comments rules");
}
}
const processedTokenColors = replaceVariables(tokenColors);
// Ensure output directory exists
const outputDir = path.dirname(OUTPUT_FILE);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Build final theme object
const newTheme = {
name: "Your Theme Name", // change to your theme's display name
type: "dark", // or "light"
colors: uiColors,
tokenColors: processedTokenColors,
semanticTokenColors: semanticColors,
};
// Write JSON
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(newTheme, null, 2));
console.log("✅ Theme built successfully!");
Important notes:
- The
orderarray controls the merge priority: rules from later files override earlier ones for the same scope. Put general rules first, then language-specific ones. - If a variable is missing, the script will warn and leave
${var}unchanged. Make sure all variables are defined. - Opacity suffixes are only supported as two hex digits (
20). Do not pre‑include opacity in your variable definitions.
⚙️ Step 5: Add Scripts to package.json
Add build commands to your package.json:
{
"scripts": {
"build": "node scripts/build.js",
"prepublishOnly": "npm run build"
}
}
Double-check that js-yaml is in devDependencies, not dependencies.
✨ Step 6: Add Watch Mode for Development
Running npm run build every time you change a YAML file gets tedious. Add a watch mode to auto‑rebuild.
1. Install nodemon
npm install --save-dev nodemon
2. Add watch scripts to package.json
{
"scripts": {
"build": "node scripts/build.js",
"watch": "nodemon --watch src -e yaml --exec \"npm run build\"",
"dev": "npm run watch",
"prepublishOnly": "npm run build"
}
}
Now run npm run watch in your terminal. Whenever you save a YAML file inside src/, the build script will run automatically. In the Extension Development Host window (F5), you can then reload (Ctrl+R) to see changes instantly.
📦 Step 7: Update .vscodeignore
Make sure your .vscodeignore excludes source files and dependencies, but keeps the generated JSON. A minimal example:
.vscode/**
.gitignore
vsc-extension-quickstart.md
node_modules
pnpm-lock.yaml
src/**
scripts/**
!themes/*.json
💡 !themes/*.json tells the packager to include all JSON files in the themes folder.
✅ Step 8: Test the Build
Run the build:
npm run build
Check the generated themes/your-theme.json against your original JSON (if you still have it). Use a diff tool to spot any unintended differences. Common issues:
- Missing variables → ensure all used variables are defined in
colors.yaml. - Opacity not working → check that opacity is applied as a two‑digit suffix (
${primary}20) and that the base color is a 6‑digit hex. - Wrong rule order → adjust the
orderarray inbuild.js.
🚀 Step 9: Enjoy Engineering Your Theme
Now your theme source is modular and easy to maintain:
- Want to change the primary color? Edit one line in
colors.yaml. - Need a new rule for Python? Just add it to
python.yaml. - Thinking about a light version? Later we’ll see how to add multiple color schemes with almost zero extra work.
🔍 Troubleshooting
| Problem | Possible Cause | Solution |
|---|---|---|
Colors appear as ${var} in the JSON |
Variable not defined in colors.yaml
|
Check spelling; add the missing variable |
| Opacity not applied correctly | Variable already includes alpha or opacity suffix wrong | Remove alpha from variable; use ${var}20
|
| Some language rules missing | Language file not listed in order
|
Add file name to the order array |
| Rules overridden unintentionally | Merge order wrong | Place more specific rules later in order
|
vsce package fails because of missing dependencies |
js-yaml in dependencies
|
Move it to devDependencies
|
| Variable names with hyphens or special characters not replaced | Variable name contains characters not supported by the variable replacement regex (e.g., hyphens -) |
Use only letters, numbers, and underscores in variable names. If you need hyphens, ensure your build script uses a regex that supports them (e.g., [a-zA-Z0-9_-]+). |
📝 Summary
By engineering your theme, you’ve moved from a fragile, monolithic JSON to a clean, scalable design system. This not only improves maintainability but also makes it easy to add more features and even create an entire family of themes.
But what if you want both a dark and a light version, and you want them to feel visually balanced? That’s exactly what Part 3 covers—building multi‑theme variants and introducing the concept of Gravity Compensation to ensure consistent visual weight across themes.
Part 3: Light, Dark, and Beyond: Building Multi‑theme Variants with Gravity Compensation (coming soon)
This article is part of the **VS Code Theme Development Guide* series. The original Chinese version is available on my blog: moongate.top.
Try the theme: Moongate Theme on VS Code Marketplace
Star on GitHub: GitHub Repo*
© 2026 yuelinghuashu. This work is licensed under CC BY-NC 4.0.
Top comments (0)