When writing technical documentation, diagrams are essential for explaining complex architectures and workflows. PlantUML is a fantastic tool for creating diagrams from simple text descriptions, but most Hugo implementations rely on external web services or require manual diagram generation. Today, I’ll show you how to build a complete Hugo extension that processes PlantUML diagrams locally using Hugo shortcodes—no external dependencies, no source file modifications, just seamless integration.

Why Build a Local PlantUML Extension?

Most Hugo PlantUML solutions have significant limitations:

  • External web services: Slow, unreliable, and expose your diagram content to third parties
  • Manual processing: Time-consuming and error-prone workflow
  • No caching: Regenerates unchanged diagrams on every build
  • Source file pollution: HTML comments or modifications in your markdown

Our solution addresses all these pain points:

Completely local processing using PlantUML JAR file
Hugo shortcode integration with pre-generated SVGs
Smart caching based on content hashing
Source files untouched - clean markdown with Hugo shortcodes
Professional styling with responsive SVG rendering

Architecture Overview

Here’s how our PlantUML extension works:

PlantUML Extension Architecture

Prerequisites

Before we start, make sure you have:

  • Node.js (for npm scripts and file processing)
  • Java Runtime Environment (to run PlantUML JAR)
  • Graphviz (required by PlantUML for certain diagram types)
  • A Hugo site with module support

Step 1: Project Structure Setup

First, let’s create the directory structure for our extension:

1# Create the bin directory for our scripts
2mkdir -p bin
3
4# Create static directory for generated SVGs
5mkdir -p static/diagrams
6
7# Create layouts directory for Hugo shortcode
8mkdir -p layouts/shortcodes

Step 2: Download and Setup PlantUML

Create a setup script that handles the initial environment configuration:

bin/setup-plantuml.js
  1#!/usr/bin/env node
  2
  3const fs = require('fs');
  4const path = require('path');
  5const { execSync } = require('child_process');
  6
  7const PLANTUML_JAR = path.join(__dirname, 'plantuml.jar');
  8const STATIC_DIAGRAMS_DIR = path.join(process.cwd(), 'static', 'diagrams');
  9const TEMP_DIR = path.join(process.cwd(), 'temp', 'plantuml');
 10
 11async function setupPlantUML() {
 12  console.log('🚀 Setting up PlantUML environment...');
 13  
 14  // Create necessary directories
 15  [STATIC_DIAGRAMS_DIR, TEMP_DIR].forEach(dir => {
 16    if (!fs.existsSync(dir)) {
 17      fs.mkdirSync(dir, { recursive: true });
 18      console.log(`✓ Created directory: ${path.relative(process.cwd(), dir)}`);
 19    }
 20  });
 21  
 22  // Download PlantUML JAR if it doesn't exist
 23  if (!fs.existsSync(PLANTUML_JAR)) {
 24    console.log('📦 Downloading PlantUML JAR...');
 25    try {
 26      execSync(`curl -L -o "${PLANTUML_JAR}" "https://github.com/plantuml/plantuml/releases/latest/download/plantuml.jar"`, 
 27        { stdio: 'inherit' });
 28      console.log('✓ PlantUML JAR downloaded successfully');
 29    } catch (error) {
 30      console.error('❌ Failed to download PlantUML JAR:', error.message);
 31      console.log('You can manually download it from: https://github.com/plantuml/plantuml/releases/latest');
 32      process.exit(1);
 33    }
 34  } else {
 35    console.log('✓ PlantUML JAR already exists');
 36  }
 37  
 38  // Check Java installation
 39  try {
 40    const javaVersion = execSync('java -version 2>&1', { encoding: 'utf-8' });
 41    console.log('✓ Java is installed:', javaVersion.split('\n')[0]);
 42  } catch (error) {
 43    console.error('❌ Java is not installed or not in PATH');
 44    console.log('Please install Java to use PlantUML');
 45    process.exit(1);
 46  }
 47  
 48  // Check Graphviz installation
 49  try {
 50    const dotVersion = execSync('dot -V 2>&1', { encoding: 'utf-8' });
 51    console.log('✓ Graphviz is installed:', dotVersion.trim());
 52  } catch (error) {
 53    console.warn('⚠️  Graphviz is not installed. Some PlantUML diagrams may not work properly.');
 54    console.log('To install Graphviz:');
 55    console.log('  Ubuntu/Debian: sudo apt install graphviz');
 56    console.log('  macOS: brew install graphviz');
 57    console.log('  Windows: Download from https://graphviz.org/download/');
 58  }
 59  
 60  // Test PlantUML with Graphviz
 61  try {
 62    execSync(`java -jar "${PLANTUML_JAR}" -testdot`, { stdio: 'pipe' });
 63    console.log('✓ PlantUML + Graphviz integration working');
 64  } catch (error) {
 65    console.warn('⚠️  PlantUML may have issues with Graphviz integration');
 66  }
 67  
 68  // Create gitignore entries
 69  const gitignorePath = path.join(process.cwd(), '.gitignore');
 70  const gitignoreEntries = [
 71    '# PlantUML temp files',
 72    'temp/plantuml/',
 73    '# PlantUML JAR (downloaded automatically)',
 74    'bin/plantuml.jar'
 75  ].join('\n');
 76  
 77  if (fs.existsSync(gitignorePath)) {
 78    const gitignoreContent = fs.readFileSync(gitignorePath, 'utf-8');
 79    if (!gitignoreContent.includes('temp/plantuml/')) {
 80      fs.appendFileSync(gitignorePath, '\n' + gitignoreEntries + '\n');
 81      console.log('✓ Updated .gitignore with PlantUML entries');
 82    }
 83  } else {
 84    fs.writeFileSync(gitignorePath, gitignoreEntries + '\n');
 85    console.log('✓ Created .gitignore with PlantUML entries');
 86  }
 87  
 88  console.log('✅ PlantUML setup complete!');
 89  console.log('');
 90  console.log('Usage:');
 91  console.log('  npm run plantuml:process  - Process PlantUML diagrams');
 92  console.log('  npm run dev              - Start development server with PlantUML processing');
 93  console.log('  npm run build            - Build site with PlantUML processing');
 94}
 95
 96if (require.main === module) {
 97  setupPlantUML();
 98}
 99
100module.exports = { setupPlantUML };

Step 3: The SVG Generation Script

Now, let’s create the script that generates SVG files from Hugo shortcode content:

bin/generate-plantuml-svgs.js
  1#!/usr/bin/env node
  2
  3const fs = require('fs');
  4const path = require('path');
  5const { execSync } = require('child_process');
  6const crypto = require('crypto');
  7
  8// Configuration
  9const PLANTUML_JAR = path.join(__dirname, 'plantuml.jar');
 10const CONTENT_DIR = path.join(process.cwd(), 'content');
 11const STATIC_DIR = path.join(process.cwd(), 'static', 'diagrams');
 12const TEMP_DIR = path.join(process.cwd(), 'temp', 'plantuml');
 13
 14// Ensure directories exist
 15[STATIC_DIR, TEMP_DIR].forEach(dir => {
 16  if (!fs.existsSync(dir)) {
 17    fs.mkdirSync(dir, { recursive: true });
 18  }
 19});
 20
 21// Check if PlantUML JAR exists
 22function ensurePlantUMLJar() {
 23  if (!fs.existsSync(PLANTUML_JAR)) {
 24    console.log('❌ PlantUML JAR not found. Run: npm run plantuml:setup');
 25    process.exit(1);
 26  }
 27}
 28
 29// Generate hash for PlantUML content (matching Hugo's processing)
 30function getContentHash(content) {
 31  let processed = content.trim();
 32  
 33  return crypto.createHash('sha256').update(processed).digest('hex').substring(0, 8);
 34}
 35
 36// Extract PlantUML blocks from markdown content within shortcodes
 37function extractPlantUMLShortcodes(content) {
 38  const blocks = [];
 39  // Match both {{< plantuml >}} and {{< plantuml alt="..." >}} formats
 40  const regex = /\{\{<\s*plantuml[^>]*>\}\}([\s\S]*?)\{\{<\s*\/plantuml\s*>\}\}/g;
 41  let match;
 42  
 43  while ((match = regex.exec(content)) !== null) {
 44    const plantUMLContent = match[1];
 45    // Remove markdown code block wrapper if present, but keep other formatting
 46    const cleanContent = plantUMLContent.replace(/^```plantuml\s*\n/, '').replace(/\n```$/, '');
 47    
 48    blocks.push({
 49      fullMatch: match[0],
 50      content: cleanContent, // Don't trim here - let getContentHash handle it consistently
 51      index: match.index
 52    });
 53  }
 54  
 55  return blocks;
 56}
 57
 58// Process a single PlantUML block and generate SVG
 59function processPlantUMLBlock(block, filePath, blockIndex) {
 60  const hash = getContentHash(block.content);
 61  const fileName = path.basename(filePath, '.md');
 62  const outputName = `${fileName}-${hash}.svg`;
 63  const outputPath = path.join(STATIC_DIR, outputName);
 64  
 65  // Skip if already generated
 66  if (fs.existsSync(outputPath)) {
 67    return `/diagrams/${outputName}`;
 68  }
 69  
 70  try {
 71    const tempFile = path.join(TEMP_DIR, `${hash}.puml`);
 72    const tempOutputDir = path.join(TEMP_DIR, 'output');
 73    
 74    // Ensure temp output directory exists
 75    if (!fs.existsSync(tempOutputDir)) {
 76      fs.mkdirSync(tempOutputDir, { recursive: true });
 77    }
 78    
 79    // Write PlantUML content to temp file
 80    fs.writeFileSync(tempFile, block.content);
 81    
 82    // Generate SVG using PlantUML JAR
 83    execSync(`java -jar "${PLANTUML_JAR}" -tsvg -o "${tempOutputDir}" "${tempFile}"`, 
 84      { stdio: 'pipe' });
 85    
 86    // Find generated SVG (PlantUML may use diagram title as filename)
 87    const generatedFiles = fs.readdirSync(tempOutputDir).filter(f => f.endsWith('.svg'));
 88    
 89    if (generatedFiles.length > 0) {
 90      const generatedFile = path.join(tempOutputDir, generatedFiles[0]);
 91      fs.renameSync(generatedFile, outputPath);
 92      console.log(`✓ Generated: ${outputName}`);
 93      
 94      // Clean up temp files
 95      fs.unlinkSync(tempFile);
 96      generatedFiles.forEach(f => {
 97        const tempGenFile = path.join(tempOutputDir, f);
 98        if (fs.existsSync(tempGenFile)) {
 99          fs.unlinkSync(tempGenFile);
100        }
101      });
102    }
103    
104    return `/diagrams/${outputName}`;
105    
106  } catch (error) {
107    console.error(`❌ Failed to process PlantUML in ${filePath}:`, error.message);
108    return null;
109  }
110}
111
112// Process all markdown files and generate SVGs (without modifying source)
113function generateSVGsOnly() {
114  const markdownFiles = [];
115  
116  function findMarkdownFiles(dir) {
117    const items = fs.readdirSync(dir);
118    
119    for (const item of items) {
120      const itemPath = path.join(dir, item);
121      const stat = fs.statSync(itemPath);
122      
123      if (stat.isDirectory()) {
124        findMarkdownFiles(itemPath);
125      } else if (item.endsWith('.md')) {
126        markdownFiles.push(itemPath);
127      }
128    }
129  }
130  
131  findMarkdownFiles(CONTENT_DIR);
132  
133  let generatedCount = 0;
134  
135  for (const filePath of markdownFiles) {
136    const content = fs.readFileSync(filePath, 'utf-8');
137    const blocks = extractPlantUMLShortcodes(content);
138    
139    if (blocks.length === 0) continue;
140    
141    console.log(`📄 Processing ${path.relative(process.cwd(), filePath)}`);
142    
143    blocks.forEach((block, index) => {
144      const svgPath = processPlantUMLBlock(block, filePath, index);
145      if (svgPath) {
146        generatedCount++;
147      }
148    });
149  }
150  
151  console.log(`✅ Generated ${generatedCount} SVG diagrams`);
152}
153
154// Main execution
155function main() {
156  console.log('🎨 Generating PlantUML SVGs (source files unchanged)...');
157  
158  ensurePlantUMLJar();
159  generateSVGsOnly();
160  
161  console.log('✅ SVG generation complete!');
162}
163
164if (require.main === module) {
165  main();
166}
167
168module.exports = { generateSVGsOnly };

Step 4: Hugo Shortcode for Rendering

Create a Hugo shortcode that renders the pre-generated SVG files:

layouts/shortcodes/plantuml.html
 1{{- $content := .Inner -}}
 2{{- $trimmed := $content | strings.TrimSpace -}}
 3{{- $hash := ($trimmed | sha256 | truncate 8 "") -}}
 4{{- $filename := printf "%s-%s.svg" (.Page.File.BaseFileName | default "diagram") $hash -}}
 5{{- $svgPath := printf "/diagrams/%s" $filename -}}
 6
 7<div class="plantuml-diagram">
 8  <img 
 9    src="{{ $svgPath }}" 
10    alt="{{ .Get "alt" | default "PlantUML Diagram" }}" 
11    loading="lazy"
12  />
13</div>

This shortcode:

  • Calculates the same hash as the generation script
  • Constructs the SVG file path using the page name and content hash
  • Renders a responsive image with professional styling
  • Never modifies your source markdown files

Step 5: npm Scripts Integration

Add these scripts to your package.json:

package.json
{
  "scripts": {
    "dev": "npm run plantuml:generate && hugo server --buildFuture --buildDrafts --disableFastRender",
    "dev:future": "npm run plantuml:generate && hugo server --buildFuture",
    "build": "npm run plantuml:generate && hugo --minify",
    "build:future": "npm run plantuml:generate && hugo --buildFuture --minify",
    "plantuml:generate": "node bin/generate-plantuml-svgs.js",
    "plantuml:setup": "node bin/setup-plantuml.js"
  }
}

Step 6: Hugo Configuration

Add the diagrams directory to your Hugo module mounts in config.toml:

config.tomlLines 86-88
86[[module.mounts]]
87source = "static/diagrams"
88target = "static/diagrams"

This ensures Hugo properly serves the generated SVG files.

Step 7: Git Configuration

Add these entries to your .gitignore:

1# PlantUML temp files
2temp/*
3
4# PlantUML JAR (downloaded automatically)
5bin/plantuml.jar

The generated SVG files in static/diagrams/ should be committed to version control alongside your content.

Usage Workflow

Initial Setup

This will:

  • Download PlantUML JAR
  • Create necessary directories
  • Verify Java and Graphviz installation
  • Test PlantUML integration
1# Install dependencies
2npm run plantuml:setup

Development Workflow

  1. Write PlantUML in your markdown files using Hugo shortcodes:
{{<plantuml alt="System Architecture Diagram">}}
@startuml System Architecture

actor User
participant Frontend
participant API
database Database

User -> Frontend: Request
Frontend -> API: HTTP call
API -> Database: Query
Database -> API: Results
API -> Frontend: JSON response
Frontend -> User: Display

@enduml
{{</plantuml>}}

The diagram shows...

And here’s the actual rendered diagram:

System Architecture Diagram
  1. Generate SVG files:
npm run plantuml:generate

The script will:

  • Find all Hugo shortcodes containing PlantUML content
  • Generate SVG files with content-based hashing
  • Save files to static/diagrams/ directory
  • Leave your source markdown files completely untouched
  1. Start development server:
npm run dev  # Generates SVGs + starts Hugo server
  1. Your shortcode renders the pre-generated SVG:

The Hugo shortcode automatically:

  • Calculates the same hash from the PlantUML content
  • References the correct SVG file: /diagrams/filename-hash.svg
  • Renders a responsive, styled diagram

Advanced Features

Content-based caching: Only regenerates diagrams when PlantUML source changes

Hugo-native rendering: Uses pure Hugo shortcodes with no source file modifications

Multiple diagrams per file: Each gets a unique hash based on content

  • filename-abc123de.svg
  • filename-xyz789fg.svg
  • etc.

Responsive styling: SVG diagrams scale properly on all device sizes

Benefits of This Approach

🚀 Performance

  • Local processing is faster than external services
  • Smart caching prevents unnecessary regeneration
  • SVG files are served directly by Hugo

🔒 Security & Privacy

  • No external network calls during build
  • Diagram content never leaves your environment
  • Works completely offline

🛠 Developer Experience

  • Clean Hugo shortcode integration
  • Preview diagrams instantly during development
  • No backup/restore complexity - source files stay clean
  • Content-based hashing prevents unnecessary rebuilds

📦 Maintainability

  • Version control for both source and generated SVGs
  • Reproducible builds across environments
  • Hugo shortcodes keep content semantic and clean
  • No file modification or temporary backup complexity

Troubleshooting

Common Issues

“Java not found”: Install Java JRE/JDK

1# Ubuntu/Debian
2sudo apt install default-jre
3
4# macOS
5brew install openjdk

“Graphviz not found”: Install Graphviz for full diagram support

1# Ubuntu/Debian
2sudo apt install graphviz
3
4# macOS
5brew install graphviz

“PlantUML syntax error”: Check your PlantUML syntax at plantuml.com

Debugging

Enable verbose output by modifying the generation script to include { stdio: 'inherit' } in the execSync calls.

Hash Mismatch Issues

If diagrams don’t load, ensure the generation script and Hugo shortcode are calculating the same hash:

  1. Check that both use the same trimming behavior
  2. Verify the content between shortcode tags matches exactly
  3. Regenerate SVGs: npm run plantuml:generate

Extending the System

Custom Styling

Modify the Hugo shortcode to add custom CSS classes or styling:

<!-- layouts/shortcodes/plantuml.html -->
{{< $content := .Inner | trim " \n\r\t" >}}
{{< $hash := ($content | sha256 | truncate 8 "") >}}
{{< $filename := printf "%s-%s.svg" (.Page.File.BaseFileName | default "diagram") $hash >}}
{{< $svgPath := printf "/diagrams/%s" $filename >}}

<div class="plantuml {{/* .Get "class" | default "" */}}">
  <figure>
    <img 
      src="{{/* $svgPath */}}" 
      alt="{{/* .Get "alt" | default "PlantUML Diagram" */}}" 
      style="max-width: 100%; height: auto;"
    />
    {{/* with .Get "caption" */}}
    <figcaption>{{/* . */}}</figcaption>
    {{/* end */}}
  </figure>
</div>

Then use it with:

1{{< plantuml alt="My Diagram" caption="System Architecture Overview" class="centered" >}}
2@startuml
3...
4@enduml
5{{< /plantuml >}}

Multiple Output Formats

Extend the generation script to create multiple formats:

1// Generate both SVG and PNG
2execSync(`java -jar "${PLANTUML_JAR}" -tsvg -tpng -o "${tempOutputDir}" "${tempFile}"`);

Different Diagram Types

The shortcode approach works with all PlantUML diagram types:

 1{{< plantuml alt="Sequence Diagram" >}}
 2@startuml
 3Alice -> Bob: Authentication Request
 4Bob -> Alice: Authentication Response
 5@enduml
 6{{< /plantuml >}}
 7
 8{{< plantuml alt="Class Diagram" >}}
 9@startuml
10class User {
11  +String name
12  +login()
13}
14class Admin extends User {
15  +deleteUser()
16}
17@enduml
18{{< /plantuml >}}
Sequence Diagram
Class Diagram

CI/CD Integration

The extension works seamlessly in CI/CD environments. Just ensure Java and Graphviz are available in your build container.

Conclusion

This Hugo PlantUML extension provides a robust, local solution for diagram generation that integrates seamlessly with Hugo’s native shortcode system. By using a two-phase approach—SVG generation followed by Hugo rendering—you get the best of both worlds: local processing with clean, untouched source files.

The content-based caching system ensures efficient rebuilds, while the Hugo shortcode approach maintains semantic, readable markdown without any file modifications or backup complexity.

Key advantages of this approach:

Clean source files: No HTML comments or modifications in your markdown
Hugo-native: Uses standard shortcode syntax that Hugo developers expect
Efficient caching: Only regenerates changed diagrams
Local processing: No external dependencies or network calls
Professional rendering: Responsive, styled diagrams that integrate with your theme

Happy diagramming! 🎨