Chris Wiegman

Better Taxonomy Management for Hugo with VS Code

| 6 min read
Better Taxonomy Management for Hugo with VS Code

One of the things I need to be careful about when using Hugo is making sure the categories and tags used on a post stay consistent. In the past, even with WordPress, I had hundreds of tags and even extra categories that either didn’t make sense or were there because I screwed something up, like a minor typo in the tag name.

With a little bit of work I now have a solution that works for me well in VS Code by letting me autocomplete the tags and categories I’m already using throughout this site. I just start typing and it lets me know if a tag has been used before or not.

Here’s how it works:

Extract the Tags and Categories and save Them to JSON

The first piece is a Node.js script that extracts all the tags and categories and saves them to .vscode/tags-categories.json and .vscode/markdown.code-snippets.

The former file I use for reporting. The latter is what actually does the magic in VS Code.

Save this to scripts/extract-tags.js

#!/usr/bin/env node

/**
 * Extracts all unique categories and tags from published Hugo posts.
 * Outputs a JSON file that can be used for autocomplete/reference.
 */

const fs = require("fs");
const path = require("path");

const CONTENT_DIR = path.join(__dirname, "..", "content", "posts");
const OUTPUT_FILE = path.join(
  __dirname,
  "..",
  ".vscode",
  "tags-categories.json",
);
const SNIPPETS_FILE = path.join(
  __dirname,
  "..",
  ".vscode",
  "markdown.code-snippets",
);

/**
 * Extracts frontmatter from a markdown file
 */
function extractFrontmatter(content) {
  const match = content.match(/^---\n([\s\S]*?)\n---/);
  return match ? match[1] : null;
}

/**
 * Parses YAML-style list items from frontmatter
 */
function parseListItems(frontmatter, key) {
  const items = [];
  const blockRegex = new RegExp(`^${key}:\\s*\\n((\\s*-\\s*.+\\n)+)`, "m");
  const match = frontmatter.match(blockRegex);

  if (match) {
    const lines = match[1].split("\n");
    for (const line of lines) {
      const itemMatch = line.match(/^\s*-\s*(.+)\s*$/);
      if (itemMatch) {
        const value = itemMatch[1].trim().replace(/^["']|["']$/g, "");
        if (value) {
          items.push(value);
        }
      }
    }
  }

  return items;
}

/**
 * Recursively finds all markdown files
 */
function findMarkdownFiles(dir) {
  const files = [];
  const entries = fs.readdirSync(dir, { withFileTypes: true });

  for (const entry of entries) {
    const fullPath = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      files.push(...findMarkdownFiles(fullPath));
    } else if (entry.isFile() && entry.name.endsWith(".md")) {
      files.push(fullPath);
    }
  }

  return files;
}

/**
 * Main extraction logic
 */
function extractTagsAndCategories() {
  const categories = new Set();
  const tags = new Set();
  const categoryCount = {};
  const tagCount = {};

  const files = findMarkdownFiles(CONTENT_DIR);
  console.log(`Found ${files.length} markdown files`);

  for (const file of files) {
    const content = fs.readFileSync(file, "utf8");
    const frontmatter = extractFrontmatter(content);

    if (!frontmatter) continue;

    // Extract categories
    const fileCategories = parseListItems(frontmatter, "categories");
    for (const cat of fileCategories) {
      categories.add(cat);
      categoryCount[cat] = (categoryCount[cat] || 0) + 1;
    }

    // Extract tags
    const fileTags = parseListItems(frontmatter, "tags");
    for (const tag of fileTags) {
      tags.add(tag);
      tagCount[tag] = (tagCount[tag] || 0) + 1;
    }
  }

  // Sort by usage count (most used first)
  const sortedCategories = Array.from(categories).sort((a, b) => {
    return (categoryCount[b] || 0) - (categoryCount[a] || 0);
  });

  const sortedTags = Array.from(tags).sort((a, b) => {
    return (tagCount[b] || 0) - (tagCount[a] || 0);
  });

  return {
    categories: sortedCategories,
    tags: sortedTags,
    stats: {
      categoryCount,
      tagCount,
      totalCategories: categories.size,
      totalTags: tags.size,
      totalFiles: files.length,
      generatedAt: new Date().toISOString(),
    },
  };
}

/**
 * Generate snippet prefixes for a tag/category
 */
function generatePrefixes(name) {
  const lower = name.toLowerCase();
  const prefixes = [lower];

  // Add concatenated version for multi-word items (e.g., "Digital Life" -> "digitallife")
  if (name.includes(" ")) {
    const concat = lower.replace(/\s+/g, "");
    if (concat !== lower) {
      prefixes.push(concat);
    }
  }

  // Add common abbreviations
  const abbrevMap = {
    Technology: ["tech"],
    Personal: ["pers"],
    WordPress: ["wp"],
    Infrastructure: ["infra"],
    "Open Source": ["oss"],
    Reflection: ["reflect"],
    "Web Development": ["webdev", "web"],
    Development: ["dev"],
    "Digital Life": ["digital"],
    "Social Media": ["social"],
    Education: ["edu"],
    "Self-hosting": ["selfhost"],
    "Content Management Systems": ["cms", "contentmanagement"],
  };

  if (abbrevMap[name]) {
    prefixes.push(...abbrevMap[name]);
  }

  // Return single string if only one prefix, otherwise return array
  return prefixes.length === 1 ? prefixes[0] : prefixes;
}

/**
 * Generate VS Code snippets from tags and categories
 */
function generateSnippets(data) {
  const snippets = {};

  // Generate category snippets
  for (const category of data.categories) {
    snippets[`Category: ${category}`] = {
      prefix: generatePrefixes(category),
      body: category,
      description: `Category: ${category}`,
    };
  }

  // Generate tag snippets
  for (const tag of data.tags) {
    snippets[`Tag: ${tag}`] = {
      prefix: generatePrefixes(tag),
      body: tag,
      description: `Tag: ${tag}`,
    };
  }

  return snippets;
}

/**
 * Main execution
 */
function main() {
  console.log("Extracting tags and categories from published posts...");

  const data = extractTagsAndCategories();

  // Ensure .vscode directory exists
  const vscodePath = path.dirname(OUTPUT_FILE);
  if (!fs.existsSync(vscodePath)) {
    fs.mkdirSync(vscodePath, { recursive: true });
  }

  // Write JSON data file
  fs.writeFileSync(OUTPUT_FILE, JSON.stringify(data, null, 2));

  // Generate and write snippets file
  const snippets = generateSnippets(data);
  fs.writeFileSync(SNIPPETS_FILE, JSON.stringify(snippets, null, 2));

  console.log("\nResults:");
  console.log(`  Categories: ${data.stats.totalCategories}`);
  console.log(`  Tags: ${data.stats.totalTags}`);
  console.log(`  Files processed: ${data.stats.totalFiles}`);
  console.log(`\nOutput written to:`);
  console.log(`  - ${OUTPUT_FILE}`);
  console.log(`  - ${SNIPPETS_FILE}`);
  console.log("\nMost used categories:");
  data.categories.slice(0, 5).forEach((cat) => {
    console.log(`  - ${cat} (${data.stats.categoryCount[cat]} posts)`);
  });
  console.log("\nMost used tags:");
  data.tags.slice(0, 10).forEach((tag) => {
    console.log(`  - ${tag} (${data.stats.tagCount[tag]} posts)`);
  });
}

main();

Running the Script

I have three ways to run the script above to update the code snippets. I use a VS Code Task, the terminal via a Make target, or automatically every week via GitHub action. Frankly, the GitHub action is enough but the other options just make it easier for me if I know I’ve changed the list.

Makefile

Add the following to Makefile in your project’s root:

.PHONY: extract-tags
extract-tags:
	node scripts/extract-tags.js

VS Code Task

This one you’ll save to .vscode/tasks.json. You can then run it by selecting “Run Task” from the command palette.

{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "Extract Tags",
            "type": "shell",
            "command": "make extract-tags",
            "problemMatcher": []
        }
    ]
}

GitHub Action

Finally, this GitHub Action will run every Saturday, or you can trigger it manually by visiting your repo’s Actions page on the GitHub website:

Save this as .github/workflows/update-taxonomies.yml.

name: Update Taxonomy Database

on:
  schedule:
    # Run every Saturday at 00:00 UTC
    - cron: "0 0 * * 6"
  workflow_dispatch: # Allow manual triggering

permissions:
  contents: write

jobs:
  update-tags:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Set up Node.js
        uses: actions/setup-node@v6
        with:
          node-version: "lts/*"

      - name: Run extract-tags script
        run: node scripts/extract-tags.js

      - name: Commit changes
        uses: EndBug/add-and-commit@v9
        with:
          add: ".vscode/tags-categories.json .vscode/markdown.code-snippets"
          default_author: github_actions
          message: "Update tags and categories database"
          committer_name: GitHub Actions
          committer_email: actions@github.com

Updating Your Project Settings

Once you can run the script you need to update your project’s settings in VS Code to actually trigger the snippets.

Save the following to .vscode/settings.json.

{
  "[markdown]": {
    "editor.quickSuggestions": {
      "other": false,
      "comments": false,
      "strings": true
    },
    "editor.snippetSuggestions": "top",
    "editor.suggest.showSnippets": true,
    "editor.wordBasedSuggestions": "off"
  }
}

Wrapping Up

That’s it. Now VS Code will automatically complete your tags and categories based on those already in use around the site. This approach has helped me maintain a much cleaner site structure, preventing the tag sprawl I experienced in the past. The autocomplete makes it easy to reuse existing tags rather than accidentally creating duplicates with slight variations.