DEV Community

yuelinghuashu
yuelinghuashu

Posted on

Stop Hand-Editing JSON: Engineering Your VS Code Theme with YAML

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 tokenColors array—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
Enter fullscreen mode Exit fullscreen mode

⚠️ Note: js-yaml must 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
Enter fullscreen mode Exit fullscreen mode

🎨 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Move semanticTokenColors to src/semantic.yaml:

# Semantic highlighting rules
variable: "${variable}"
function: "${function}"
class: "${warning}"
"*.decorator":
  foreground: "${purple}"
  fontStyle: "italic"
# ... other semantic rules
Enter fullscreen mode Exit fullscreen mode

🔍 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:

  1. Load colors.yaml to get the variable dictionary.
  2. Load workbench.yaml, semantic.yaml, and all language rules.
  3. Recursively replace ${variable} with actual color values (and handle opacity suffixes).
  4. Merge tokenColors in a specified order.
  5. 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!");
Enter fullscreen mode Exit fullscreen mode

Important notes:

  • The order array 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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

💡 !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
Enter fullscreen mode Exit fullscreen mode

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 order array in build.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)