
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.comUpdating 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.