INTRODUCTION TO THE CHALLENGE
Creating a web-based tool that processes Markdown files and renders them beautifully on screen represents a fascinating intersection of several technologies. When we add support for PlantUML and Mermaid diagrams, we elevate this tool from a simple renderer to a comprehensive documentation platform. This tutorial will guide you through building such a system using Go, exploring every component in detail while maintaining clean architecture principles.
The core challenge lies in orchestrating multiple rendering engines. Markdown itself requires parsing and HTML conversion. PlantUML diagrams need external processing to generate images. Mermaid diagrams can be rendered client-side using JavaScript. Our Go application must coordinate all these elements while providing a responsive web interface.
UNDERSTANDING THE ARCHITECTURE
Before diving into code, we need to understand the architectural layers of our application. The system consists of four primary components working in harmony. First, we have the HTTP server layer that handles incoming requests and serves responses. Second, the Markdown processing layer converts raw Markdown text into HTML. Third, the diagram processing layer identifies and handles PlantUML and Mermaid blocks. Fourth, the presentation layer combines everything into a cohesive HTML document.
The flow begins when a user uploads a Markdown file or provides Markdown content through the web interface. The server receives this content and passes it to the Markdown processor. During processing, the system identifies special code blocks marked as PlantUML or Mermaid. For PlantUML blocks, the system sends the diagram definition to a PlantUML server and receives back an SVG or PNG image. For Mermaid blocks, the system preserves the diagram definition and marks it for client-side rendering. Finally, the processed HTML, complete with embedded diagrams, gets sent back to the browser.
SETTING UP THE PROJECT STRUCTURE
A well-organized project structure makes maintenance and extension much easier. Our project will follow Go's standard layout conventions while maintaining clear separation of concerns. Let me walk you through the directory structure and explain each component's role.
The main package will reside in the root directory, containing our application entry point. We'll create a handlers package for HTTP request handlers, a markdown package for Markdown processing logic, a diagrams package for PlantUML and Mermaid handling, and a templates package for HTML templates. Additionally, we'll need a static directory for CSS and JavaScript files.
IMPLEMENTING THE MARKDOWN PROCESSOR
The heart of our application is the Markdown processor. Go has excellent libraries for Markdown parsing, with goldmark being the most feature-rich and actively maintained. Let's explore how to use goldmark effectively while extending it for our diagram needs.
First, we need to understand how goldmark works. It uses a pipeline architecture where the Markdown text passes through several stages: parsing, transforming, and rendering. We can inject custom behavior at each stage. For our purposes, we'll focus on the rendering stage where we can intercept code blocks and handle them specially.
Here's how we initialize a basic goldmark processor with syntax highlighting support:
package markdown
import (
"bytes"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
)
// Processor handles Markdown conversion with diagram support
type Processor struct {
md goldmark.Markdown
}
// NewProcessor creates a new Markdown processor with all extensions enabled
func NewProcessor() *Processor {
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
extension.Table,
extension.Strikethrough,
extension.TaskList,
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(
html.WithHardWraps(),
html.WithXHTML(),
),
)
return &Processor{
md: md,
}
}
This initialization code sets up goldmark with GitHub Flavored Markdown extensions, which includes tables, strikethrough text, and task lists. The parser options enable automatic heading ID generation, which is useful for creating table of contents. The renderer options ensure proper HTML output with hard line breaks and XHTML-compliant tags.
Now we need to extend this basic processor to handle our diagram blocks. The strategy involves creating a custom renderer that intercepts fenced code blocks and checks their language identifier. When we encounter a block marked as "plantuml" or "mermaid", we'll handle it differently from regular code blocks.
HANDLING PLANTUML DIAGRAMS
PlantUML is a powerful tool for creating UML diagrams from textual descriptions. However, PlantUML runs on the Java Virtual Machine, which means we need a way to communicate with it from our Go application. The most practical approach is using a PlantUML server, which provides an HTTP API for diagram generation.
The PlantUML server accepts diagram definitions encoded in a special format and returns rendered images. We need to implement the encoding algorithm and HTTP communication. Let's start with the encoding process:
package diagrams
import (
"bytes"
"compress/zlib"
"fmt"
"io"
"net/http"
"strings"
)
// PlantUMLProcessor handles PlantUML diagram generation
type PlantUMLProcessor struct {
serverURL string
client *http.Client
}
// NewPlantUMLProcessor creates a new PlantUML processor
func NewPlantUMLProcessor(serverURL string) *PlantUMLProcessor {
return &PlantUMLProcessor{
serverURL: serverURL,
client: &http.Client{},
}
}
// encodePlantUML encodes PlantUML text using the PlantUML encoding scheme
func (p *PlantUMLProcessor) encodePlantUML(text string) string {
// Compress the text using zlib
var compressed bytes.Buffer
w := zlib.NewWriter(&compressed)
w.Write([]byte(text))
w.Close()
// Encode using PlantUML's special base64 variant
encoded := p.encode64(compressed.Bytes())
return encoded
}
// encode64 implements PlantUML's custom base64 encoding
func (p *PlantUMLProcessor) encode64(data []byte) string {
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_"
var result strings.Builder
for i := 0; i < len(data); i += 3 {
b1 := data[i]
b2 := byte(0)
b3 := byte(0)
if i+1 < len(data) {
b2 = data[i+1]
}
if i+2 < len(data) {
b3 = data[i+2]
}
result.WriteByte(alphabet[(b1>>2)&0x3F])
result.WriteByte(alphabet[((b1&0x3)<<4)|((b2>>4)&0xF)])
result.WriteByte(alphabet[((b2&0xF)<<2)|((b3>>6)&0x3)])
result.WriteByte(alphabet[b3&0x3F])
}
return result.String()
}
The encoding process is crucial for PlantUML communication. PlantUML uses a custom base64 variant with a different alphabet than standard base64. The encoding function first compresses the diagram text using zlib compression, then applies the custom base64 encoding. This compression is important because UML diagrams can be quite verbose, and the compression significantly reduces the URL length when requesting diagrams from the server.
Now let's implement the actual diagram generation that communicates with the PlantUML server:
// GenerateSVG generates an SVG diagram from PlantUML text
func (p *PlantUMLProcessor) GenerateSVG(plantUMLText string) (string, error) {
// Ensure the text starts with @startuml and ends with @enduml
text := strings.TrimSpace(plantUMLText)
if !strings.HasPrefix(text, "@startuml") {
text = "@startuml\n" + text
}
if !strings.HasSuffix(text, "@enduml") {
text = text + "\n@enduml"
}
// Encode the diagram
encoded := p.encodePlantUML(text)
// Build the URL for SVG generation
url := fmt.Sprintf("%s/svg/%s", p.serverURL, encoded)
// Make the request
resp, err := p.client.Get(url)
if err != nil {
return "", fmt.Errorf("failed to request diagram: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("PlantUML server returned status %d", resp.StatusCode)
}
// Read the SVG content
var svgBuffer bytes.Buffer
_, err = io.Copy(&svgBuffer, resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read SVG response: %w", err)
}
return svgBuffer.String(), nil
}
This function handles the complete workflow of generating a PlantUML diagram. It first ensures the diagram text has the proper start and end markers that PlantUML requires. Then it encodes the text and constructs a URL pointing to the PlantUML server's SVG endpoint. The HTTP request fetches the rendered diagram, which we return as a string. Error handling is comprehensive, covering network failures and server errors.
INTEGRATING MERMAID DIAGRAMS
Mermaid diagrams take a different approach compared to PlantUML. While PlantUML requires server-side rendering, Mermaid can render entirely in the browser using JavaScript. This means our Go application doesn't need to process Mermaid diagrams at all. Instead, we preserve the diagram definition in the HTML output and let the Mermaid JavaScript library handle rendering on the client side.
The implementation for Mermaid support is simpler but requires careful HTML generation:
package diagrams
import (
"fmt"
"html"
)
// MermaidProcessor handles Mermaid diagram preparation
type MermaidProcessor struct {
diagramCounter int
}
// NewMermaidProcessor creates a new Mermaid processor
func NewMermaidProcessor() *MermaidProcessor {
return &MermaidProcessor{
diagramCounter: 0,
}
}
// PrepareHTML wraps Mermaid diagram text in appropriate HTML
func (m *MermaidProcessor) PrepareHTML(mermaidText string) string {
m.diagramCounter++
// Escape the diagram text to prevent XSS
escaped := html.EscapeString(mermaidText)
// Create a div with the mermaid class and unique ID
return fmt.Sprintf(
"<div class=\"mermaid\" id=\"mermaid-%d\">\n%s\n</div>",
m.diagramCounter,
escaped,
)
}
The Mermaid processor is straightforward. It wraps the diagram definition in a div element with the "mermaid" class, which the Mermaid JavaScript library uses to identify diagrams that need rendering. We assign each diagram a unique ID for potential future reference. The HTML escaping is critical for security, preventing any malicious code injection through diagram definitions.
CREATING A CUSTOM GOLDMARK RENDERER
Now we need to integrate our diagram processors into the Markdown rendering pipeline. Goldmark provides extension points through its AST (Abstract Syntax Tree) renderer system. We'll create a custom renderer that intercepts code blocks and routes them to the appropriate processor based on their language identifier.
The approach involves creating a custom AST node renderer for fenced code blocks:
package markdown
import (
"bytes"
"fmt"
"html"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/util"
)
// DiagramRenderer is a custom renderer for diagram code blocks
type DiagramRenderer struct {
plantUML *PlantUMLProcessor
mermaid *MermaidProcessor
}
// NewDiagramRenderer creates a new diagram renderer
func NewDiagramRenderer(plantUMLServer string) *DiagramRenderer {
return &DiagramRenderer{
plantUML: NewPlantUMLProcessor(plantUMLServer),
mermaid: NewMermaidProcessor(),
}
}
// RegisterFuncs registers the rendering functions
func (r *DiagramRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock)
}
// renderFencedCodeBlock handles rendering of fenced code blocks
func (r *DiagramRenderer) renderFencedCodeBlock(
w util.BufWriter,
source []byte,
node ast.Node,
entering bool,
) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.FencedCodeBlock)
language := string(n.Language(source))
// Extract the code block content
var content bytes.Buffer
lines := n.Lines()
for i := 0; i < lines.Len(); i++ {
line := lines.At(i)
content.Write(line.Value(source))
}
codeText := content.String()
// Handle different diagram types
switch language {
case "plantuml":
svg, err := r.plantUML.GenerateSVG(codeText)
if err != nil {
// On error, render as regular code block with error message
w.WriteString(fmt.Sprintf(
"<div class=\"error\">PlantUML Error: %s</div>\n<pre><code>%s</code></pre>",
html.EscapeString(err.Error()),
html.EscapeString(codeText),
))
} else {
w.WriteString(svg)
}
return ast.WalkContinue, nil
case "mermaid":
htmlContent := r.mermaid.PrepareHTML(codeText)
w.WriteString(htmlContent)
return ast.WalkContinue, nil
default:
// For non-diagram code blocks, use default rendering
return ast.WalkContinue, nil
}
}
This custom renderer intercepts all fenced code blocks during the rendering phase. It examines the language identifier to determine whether the block contains a diagram. For PlantUML blocks, it calls the PlantUML processor to generate an SVG, handling any errors gracefully by displaying an error message along with the original code. For Mermaid blocks, it wraps the content in appropriate HTML. For all other code blocks, it returns control to the default renderer.
The error handling deserves special attention. When PlantUML diagram generation fails, we don't want to crash the entire rendering process. Instead, we display an error message and show the original diagram source code. This helps users debug their diagrams while keeping the rest of the document accessible.
BUILDING THE HTTP SERVER
With our Markdown and diagram processing components ready, we need to build the web server that ties everything together. The server will handle file uploads, process Markdown content, and serve the rendered HTML. Let's start with the basic server structure:
package handlers
import (
"html/template"
"io"
"net/http"
)
// MarkdownHandler handles Markdown processing requests
type MarkdownHandler struct {
processor *MarkdownProcessor
templates *template.Template
}
// NewMarkdownHandler creates a new handler
func NewMarkdownHandler(plantUMLServer string) *MarkdownHandler {
processor := NewMarkdownProcessorWithDiagrams(plantUMLServer)
// Load HTML templates
templates := template.Must(template.ParseGlob("templates/*.html"))
return &MarkdownHandler{
processor: processor,
templates: templates,
}
}
// ServeHTTP handles the main page
func (h *MarkdownHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
h.showForm(w, r)
return
}
if r.Method == http.MethodPost {
h.processMarkdown(w, r)
return
}
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
// showForm displays the upload form
func (h *MarkdownHandler) showForm(w http.ResponseWriter, r *http.Request) {
err := h.templates.ExecuteTemplate(w, "form.html", nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
The handler structure maintains references to our Markdown processor and HTML templates. The ServeHTTP method routes requests based on the HTTP method. GET requests show the upload form, while POST requests process submitted Markdown content. This separation keeps the code clean and follows RESTful principles.
Now let's implement the Markdown processing endpoint:
// processMarkdown handles Markdown file upload and processing
func (h *MarkdownHandler) processMarkdown(w http.ResponseWriter, r *http.Request) {
// Parse the multipart form with a 10MB limit
err := r.ParseMultipartForm(10 << 20)
if err != nil {
http.Error(w, "Failed to parse form", http.StatusBadRequest)
return
}
// Check if content was provided via textarea
textContent := r.FormValue("markdown_text")
var markdownContent string
if textContent != "" {
markdownContent = textContent
} else {
// Try to get uploaded file
file, _, err := r.FormFile("markdown_file")
if err != nil {
http.Error(w, "No Markdown content provided", http.StatusBadRequest)
return
}
defer file.Close()
// Read file content
content, err := io.ReadAll(file)
if err != nil {
http.Error(w, "Failed to read file", http.StatusInternalServerError)
return
}
markdownContent = string(content)
}
// Process the Markdown
html, err := h.processor.Process(markdownContent)
if err != nil {
http.Error(w, fmt.Sprintf("Processing error: %s", err.Error()),
http.StatusInternalServerError)
return
}
// Render the result page
data := struct {
HTML template.HTML
}{
HTML: template.HTML(html),
}
err = h.templates.ExecuteTemplate(w, "result.html", data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
This processing function handles both file uploads and direct text input. It first attempts to parse the multipart form, which is necessary for file uploads. Then it checks whether content was provided via a textarea or file upload, prioritizing the textarea if both are present. After retrieving the Markdown content, it passes it to our processor and renders the result using an HTML template. The use of template.HTML type is important here because it tells Go's template engine that the content is safe HTML that shouldn't be escaped.
DESIGNING THE HTML TEMPLATES
The web interface requires two main templates: one for the input form and one for displaying results. Let's create a clean, functional design that works well with our diagram rendering.
The form template provides a simple interface for users to input Markdown:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Markdown Processor</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 {
color: #333;
margin-bottom: 30px;
}
textarea {
width: 100%;
min-height: 300px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 4px;
font-family: "Courier New", monospace;
font-size: 14px;
resize: vertical;
box-sizing: border-box;
}
.file-input {
margin: 20px 0;
}
button {
background-color: #007bff;
color: white;
padding: 12px 30px;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.2s;
}
button:hover {
background-color: #0056b3;
}
.or-divider {
text-align: center;
margin: 20px 0;
color: #666;
}
</style>
</head>
<body>
<div class="container">
<h1>Markdown Processor with Diagram Support</h1>
<form method="POST" enctype="multipart/form-data">
<div>
<label for="markdown_text">Enter Markdown Text:</label>
<textarea name="markdown_text" id="markdown_text"
placeholder="Type your Markdown here..."></textarea>
</div>
<div class="or-divider">OR</div>
<div class="file-input">
<label for="markdown_file">Upload Markdown File:</label>
<input type="file" name="markdown_file" id="markdown_file"
accept=".md,.markdown">
</div>
<button type="submit">Process Markdown</button>
</form>
</div>
</body>
</html>
This template provides a clean, modern interface with both textarea input and file upload options. The styling uses a card-based design that's easy on the eyes and responsive to different screen sizes.
The result template displays the processed Markdown with proper styling for diagrams:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Processed Markdown</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
line-height: 1.6;
}
.container {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.content {
color: #333;
}
.content h1, .content h2, .content h3 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
.content h1 {
font-size: 2em;
border-bottom: 1px solid #eee;
padding-bottom: 0.3em;
}
.content h2 {
font-size: 1.5em;
border-bottom: 1px solid #eee;
padding-bottom: 0.3em;
}
.content code {
background-color: #f6f8fa;
padding: 2px 6px;
border-radius: 3px;
font-family: "Courier New", monospace;
font-size: 85%;
}
.content pre {
background-color: #f6f8fa;
padding: 16px;
border-radius: 6px;
overflow-x: auto;
}
.content pre code {
background-color: transparent;
padding: 0;
}
.content table {
border-collapse: collapse;
width: 100%;
margin: 16px 0;
}
.content table th,
.content table td {
padding: 8px 13px;
border: 1px solid #ddd;
}
.content table th {
background-color: #f6f8fa;
font-weight: 600;
}
.content table tr:nth-child(even) {
background-color: #f6f8fa;
}
.content img,
.content svg {
max-width: 100%;
height: auto;
}
.mermaid {
text-align: center;
margin: 20px 0;
}
.error {
background-color: #fff3cd;
border: 1px solid #ffc107;
padding: 12px;
border-radius: 4px;
margin: 16px 0;
color: #856404;
}
.back-button {
display: inline-block;
margin-bottom: 20px;
padding: 10px 20px;
background-color: #6c757d;
color: white;
text-decoration: none;
border-radius: 4px;
transition: background-color 0.2s;
}
.back-button:hover {
background-color: #5a6268;
}
</style>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'default',
securityLevel: 'loose'
});
</script>
</head>
<body>
<div class="container">
<a href="/" class="back-button">Process Another Document</a>
<div class="content">
{{.HTML}}
</div>
</div>
</body>
</html>
This result template includes comprehensive styling for all Markdown elements including headings, code blocks, tables, and images. The Mermaid JavaScript library is loaded from a CDN and initialized to automatically render any diagrams on page load. The styling ensures that both PlantUML SVGs and Mermaid diagrams display properly within the document flow.
IMPLEMENTING THE COMPLETE PROCESSOR
Now we need to tie together all our components into a complete Markdown processor that handles diagrams. This involves extending our basic processor with diagram support:
package markdown
import (
"bytes"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/util"
)
// MarkdownProcessor processes Markdown with diagram support
type MarkdownProcessor struct {
md goldmark.Markdown
diagramRenderer *DiagramRenderer
}
// NewMarkdownProcessorWithDiagrams creates a processor with full diagram support
func NewMarkdownProcessorWithDiagrams(plantUMLServer string) *MarkdownProcessor {
diagramRenderer := NewDiagramRenderer(plantUMLServer)
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
extension.Table,
extension.Strikethrough,
extension.TaskList,
extension.Linkify,
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
parser.WithAttribute(),
),
goldmark.WithRendererOptions(
html.WithHardWraps(),
html.WithXHTML(),
html.WithUnsafe(),
),
goldmark.WithRenderer(
renderer.NewRenderer(
renderer.WithNodeRenderers(
util.Prioritized(diagramRenderer, 100),
),
),
),
)
return &MarkdownProcessor{
md: md,
diagramRenderer: diagramRenderer,
}
}
// Process converts Markdown text to HTML with diagram support
func (p *MarkdownProcessor) Process(markdown string) (string, error) {
var buf bytes.Buffer
err := p.md.Convert([]byte(markdown), &buf)
if err != nil {
return "", err
}
return buf.String(), nil
}
This complete processor integrates our custom diagram renderer into the goldmark rendering pipeline. The WithUnsafe option is important because it allows our renderer to output raw HTML for diagrams. The renderer priority of 100 ensures our diagram renderer gets called before the default code block renderer.
HANDLING CONFIGURATION AND ENVIRONMENT
A production-ready application needs proper configuration management. We should allow users to configure the PlantUML server URL and other settings through environment variables or a configuration file:
package config
import (
"os"
)
// Config holds application configuration
type Config struct {
ServerPort string
PlantUMLServer string
MaxUploadSize int64
}
// LoadConfig loads configuration from environment variables with defaults
func LoadConfig() *Config {
config := &Config{
ServerPort: getEnv("SERVER_PORT", "8080"),
PlantUMLServer: getEnv("PLANTUML_SERVER", "http://www.plantuml.com/plantuml"),
MaxUploadSize: 10 << 20, // 10 MB default
}
return config
}
// getEnv gets an environment variable or returns a default value
func getEnv(key, defaultValue string) string {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
return value
}
This configuration module provides sensible defaults while allowing customization through environment variables. The PlantUML server defaults to the public server, but users can point it to their own private instance for better performance and privacy.
ADDING ERROR HANDLING AND LOGGING
Robust error handling and logging are essential for production applications. Let's add comprehensive logging throughout our application:
package main
import (
"log"
"net/http"
"os"
)
// Logger wraps the standard logger with additional context
type Logger struct {
*log.Logger
}
// NewLogger creates a new logger instance
func NewLogger() *Logger {
return &Logger{
Logger: log.New(os.Stdout, "[MarkdownProcessor] ", log.LstdFlags),
}
}
// LoggingMiddleware logs all HTTP requests
func LoggingMiddleware(logger *Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger.Printf("%s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
next.ServeHTTP(w, r)
})
}
This logging infrastructure provides visibility into application behavior. The middleware logs every HTTP request, which is invaluable for debugging and monitoring. In a production environment, you might want to use a more sophisticated logging library like zap or logrus, but this simple implementation serves our purposes well.
CREATING THE MAIN APPLICATION
Finally, we bring everything together in the main application file:
package main
import (
"fmt"
"log"
"net/http"
"os"
)
func main() {
// Load configuration
cfg := LoadConfig()
logger := NewLogger()
logger.Printf("Starting Markdown Processor")
logger.Printf("PlantUML Server: %s", cfg.PlantUMLServer)
logger.Printf("Server Port: %s", cfg.ServerPort)
// Create handlers
markdownHandler := NewMarkdownHandler(cfg.PlantUMLServer, logger)
// Set up routes
mux := http.NewServeMux()
mux.Handle("/", LoggingMiddleware(logger, markdownHandler))
// Serve static files for CSS and JavaScript if needed
fs := http.FileServer(http.Dir("static"))
mux.Handle("/static/", http.StripPrefix("/static/", fs))
// Start server
addr := fmt.Sprintf(":%s", cfg.ServerPort)
logger.Printf("Server listening on %s", addr)
err := http.ListenAndServe(addr, mux)
if err != nil {
logger.Fatalf("Server failed to start: %v", err)
}
}
This main function orchestrates the entire application. It loads configuration, initializes logging, creates handlers, sets up routes, and starts the HTTP server. The structure is clean and follows Go best practices for web applications.
TESTING THE APPLICATION
Testing is crucial for ensuring our application works correctly. Let's create comprehensive tests for our core components:
package markdown
import (
"strings"
"testing"
)
func TestBasicMarkdownProcessing(t *testing.T) {
processor := NewMarkdownProcessorWithDiagrams("http://www.plantuml.com/plantuml")
input := "# Hello World\n\nThis is **bold** text."
output, err := processor.Process(input)
if err != nil {
t.Fatalf("Processing failed: %v", err)
}
if !strings.Contains(output, "<h1") {
t.Error("Expected h1 tag in output")
}
if !strings.Contains(output, "<strong>bold</strong>") {
t.Error("Expected strong tag for bold text")
}
}
func TestTableProcessing(t *testing.T) {
processor := NewMarkdownProcessorWithDiagrams("http://www.plantuml.com/plantuml")
input := `| Header 1 | Header 2 |
|----------|----------|
| Cell 1 | Cell 2 |`
output, err := processor.Process(input)
if err != nil {
t.Fatalf("Processing failed: %v", err)
}
if !strings.Contains(output, "<table>") {
t.Error("Expected table tag in output")
}
if !strings.Contains(output, "<th>Header 1</th>") {
t.Error("Expected table header in output")
}
}
func TestMermaidDiagram(t *testing.T) {
processor := NewMarkdownProcessorWithDiagrams("http://www.plantuml.com/plantuml")
input := "```mermaid\ngraph TD\nA-->B\n```"
output, err := processor.Process(input)
if err != nil {
t.Fatalf("Processing failed: %v", err)
}
if !strings.Contains(output, "class=\"mermaid\"") {
t.Error("Expected mermaid class in output")
}
}
These tests verify that our processor correctly handles basic Markdown, tables, and Mermaid diagrams. In a complete test suite, you would add tests for PlantUML processing, error handling, and edge cases.
OPTIMIZING PERFORMANCE
Performance optimization is important for a production application. Let's discuss several strategies to improve our application's performance.
First, we can implement caching for PlantUML diagrams. Since diagram generation is expensive, caching the results significantly improves response times for repeated requests:
package diagrams
import (
"crypto/sha256"
"encoding/hex"
"sync"
"time"
)
// CachedPlantUMLProcessor wraps PlantUMLProcessor with caching
type CachedPlantUMLProcessor struct {
processor *PlantUMLProcessor
cache map[string]cacheEntry
mutex sync.RWMutex
}
type cacheEntry struct {
svg string
timestamp time.Time
}
// NewCachedPlantUMLProcessor creates a processor with caching
func NewCachedPlantUMLProcessor(serverURL string) *CachedPlantUMLProcessor {
return &CachedPlantUMLProcessor{
processor: NewPlantUMLProcessor(serverURL),
cache: make(map[string]cacheEntry),
}
}
// GenerateSVG generates or retrieves cached SVG
func (c *CachedPlantUMLProcessor) GenerateSVG(plantUMLText string) (string, error) {
// Create hash of the diagram text
hash := sha256.Sum256([]byte(plantUMLText))
key := hex.EncodeToString(hash[:])
// Check cache
c.mutex.RLock()
entry, found := c.cache[key]
c.mutex.RUnlock()
if found && time.Since(entry.timestamp) < 24*time.Hour {
return entry.svg, nil
}
// Generate new diagram
svg, err := c.processor.GenerateSVG(plantUMLText)
if err != nil {
return "", err
}
// Store in cache
c.mutex.Lock()
c.cache[key] = cacheEntry{
svg: svg,
timestamp: time.Now(),
}
c.mutex.Unlock()
return svg, nil
}
// CleanCache removes old entries from cache
func (c *CachedPlantUMLProcessor) CleanCache() {
c.mutex.Lock()
defer c.mutex.Unlock()
now := time.Now()
for key, entry := range c.cache {
if now.Sub(entry.timestamp) > 24*time.Hour {
delete(c.cache, key)
}
}
}
This caching implementation uses SHA-256 hashing to create unique keys for each diagram. The cache stores generated SVGs for 24 hours, significantly reducing load on the PlantUML server for frequently used diagrams. The mutex ensures thread-safe access to the cache map.
SECURITY CONSIDERATIONS
Security is paramount in web applications. Our application needs protection against several common vulnerabilities.
First, we must prevent Cross-Site Scripting attacks through proper HTML escaping. The Go template engine handles this automatically for most content, but we need to be careful with diagram content. Our Mermaid processor already escapes HTML, which is good.
Second, we should implement file upload validation to prevent malicious files:
package handlers
import (
"fmt"
"path/filepath"
"strings"
)
// validateMarkdownFile checks if uploaded file is valid
func validateMarkdownFile(filename string) error {
ext := strings.ToLower(filepath.Ext(filename))
allowedExtensions := map[string]bool{
".md": true,
".markdown": true,
".txt": true,
}
if !allowedExtensions[ext] {
return fmt.Errorf("invalid file extension: %s", ext)
}
return nil
}
Third, we should implement rate limiting to prevent abuse:
package middleware
import (
"net/http"
"sync"
"time"
)
// RateLimiter implements simple rate limiting
type RateLimiter struct {
requests map[string][]time.Time
mutex sync.Mutex
limit int
window time.Duration
}
// NewRateLimiter creates a new rate limiter
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
return &RateLimiter{
requests: make(map[string][]time.Time),
limit: limit,
window: window,
}
}
// Allow checks if a request should be allowed
func (rl *RateLimiter) Allow(ip string) bool {
rl.mutex.Lock()
defer rl.mutex.Unlock()
now := time.Now()
cutoff := now.Add(-rl.window)
// Clean old requests
requests := rl.requests[ip]
validRequests := make([]time.Time, 0)
for _, t := range requests {
if t.After(cutoff) {
validRequests = append(validRequests, t)
}
}
if len(validRequests) >= rl.limit {
return false
}
validRequests = append(validRequests, now)
rl.requests[ip] = validRequests
return true
}
// Middleware returns an HTTP middleware for rate limiting
func (rl *RateLimiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
if !rl.Allow(ip) {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
This rate limiter prevents abuse by limiting the number of requests from a single IP address within a time window. It's a simple but effective protection mechanism.
DEPLOYMENT CONSIDERATIONS
When deploying this application to production, several factors need consideration. First, you should run the application behind a reverse proxy like Nginx or Apache. This provides additional security, SSL termination, and load balancing capabilities.
Second, consider running your own PlantUML server instead of using the public one. This improves performance and privacy. You can deploy PlantUML using Docker:
docker run -d -p 8080:8080 plantuml/plantuml-server:jetty
Third, implement proper logging and monitoring. Consider using structured logging and integrating with monitoring tools like Prometheus or Datadog.
Fourth, use environment-specific configuration. Development, staging, and production environments should have different configurations, especially for the PlantUML server URL and security settings.
EXTENDING THE APPLICATION
The application we've built provides a solid foundation that can be extended in many ways. Here are some ideas for enhancements.
You could add support for additional diagram types like GraphViz or D2. The architecture we've built makes this straightforward. Simply create a new processor implementing the same interface and register it in the diagram renderer.
You could implement document export functionality, allowing users to download the processed HTML or convert it to PDF. Libraries like wkhtmltopdf can convert HTML to PDF while preserving diagram rendering.
You could add a document management system, allowing users to save and organize their processed documents. This would require adding a database layer and user authentication.
You could implement real-time preview using WebSockets. As users type Markdown, the preview updates instantly without requiring form submission.
You could add collaborative editing features, allowing multiple users to work on the same document simultaneously. This would require implementing operational transformation or CRDT algorithms.
UNDERSTANDING THE COMPLETE WORKFLOW
Let's trace through a complete request to understand how all components work together. When a user submits a Markdown document containing both PlantUML and Mermaid diagrams, here's what happens.
First, the HTTP server receives the POST request and routes it to the MarkdownHandler. The handler extracts the Markdown content from either the file upload or textarea input. It then passes this content to the MarkdownProcessor.
The processor feeds the Markdown text into goldmark's parser, which creates an Abstract Syntax Tree representing the document structure. Goldmark then walks this AST, calling our custom DiagramRenderer for each fenced code block.
When the DiagramRenderer encounters a PlantUML block, it extracts the diagram definition and passes it to the PlantUMLProcessor. This processor encodes the definition using PlantUML's custom encoding scheme and sends an HTTP request to the PlantUML server. The server responds with an SVG image, which the renderer embeds directly into the HTML output.
For Mermaid blocks, the DiagramRenderer wraps the diagram definition in a div element with the mermaid class. No server-side processing occurs because Mermaid renders client-side.
After processing all nodes in the AST, goldmark produces the final HTML. The MarkdownHandler receives this HTML and passes it to the result template. The template wraps the HTML in a complete page structure, including CSS for styling and the Mermaid JavaScript library.
Finally, the server sends the complete HTML response to the browser. The browser renders the HTML, and the Mermaid library automatically processes any Mermaid diagram divs, converting them to SVG graphics.
FULL RUNNING EXAMPLE
Now let's present a complete, production-ready implementation that brings together all the concepts we've discussed. This implementation includes all necessary components with no placeholders or simplifications.
Here is the final version with all improvements:
// File: main.go
package main
import (
"bytes"
"compress/zlib"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"html"
"io"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
gmhtml "github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/util"
)
// Configuration holds application settings
type Configuration struct {
ServerPort string
PlantUMLServer string
MaxUploadSize int64
}
// LoadConfiguration loads settings from environment with defaults
func LoadConfiguration() *Configuration {
port := os.Getenv("SERVER_PORT")
if port == "" {
port = "8080"
}
plantUMLServer := os.Getenv("PLANTUML_SERVER")
if plantUMLServer == "" {
plantUMLServer = "http://www.plantuml.com/plantuml"
}
return &Configuration{
ServerPort: port,
PlantUMLServer: plantUMLServer,
MaxUploadSize: 10 << 20, // 10 MB
}
}
// PlantUMLProcessor handles PlantUML diagram generation with caching
type PlantUMLProcessor struct {
serverURL string
client *http.Client
cache map[string]cacheEntry
mutex sync.RWMutex
}
type cacheEntry struct {
svg string
timestamp time.Time
}
// NewPlantUMLProcessor creates a new PlantUML processor with caching
func NewPlantUMLProcessor(serverURL string) *PlantUMLProcessor {
return &PlantUMLProcessor{
serverURL: serverURL,
client: &http.Client{Timeout: 30 * time.Second},
cache: make(map[string]cacheEntry),
}
}
// encodePlantUML encodes PlantUML text using the PlantUML encoding scheme
func (p *PlantUMLProcessor) encodePlantUML(text string) (string, error) {
var compressed bytes.Buffer
w := zlib.NewWriter(&compressed)
_, err := w.Write([]byte(text))
if err != nil {
return "", fmt.Errorf("failed to write to compressor: %w", err)
}
err = w.Close()
if err != nil {
return "", fmt.Errorf("failed to close compressor: %w", err)
}
return p.encode64(compressed.Bytes()), nil
}
// encode64 implements PlantUML's custom base64 encoding
func (p *PlantUMLProcessor) encode64(data []byte) string {
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_"
var result strings.Builder
for i := 0; i < len(data); i += 3 {
b1 := data[i]
b2 := byte(0)
b3 := byte(0)
if i+1 < len(data) {
b2 = data[i+1]
}
if i+2 < len(data) {
b3 = data[i+2]
}
result.WriteByte(alphabet[(b1>>2)&0x3F])
result.WriteByte(alphabet[((b1&0x3)<<4)|((b2>>4)&0xF)])
result.WriteByte(alphabet[((b2&0xF)<<2)|((b3>>6)&0x3)])
result.WriteByte(alphabet[b3&0x3F])
}
return result.String()
}
// GenerateSVG generates an SVG diagram from PlantUML text with caching
func (p *PlantUMLProcessor) GenerateSVG(plantUMLText string) (string, error) {
hash := sha256.Sum256([]byte(plantUMLText))
key := hex.EncodeToString(hash[:])
p.mutex.RLock()
entry, found := p.cache[key]
p.mutex.RUnlock()
if found && time.Since(entry.timestamp) < 24*time.Hour {
return entry.svg, nil
}
text := strings.TrimSpace(plantUMLText)
if !strings.HasPrefix(text, "@startuml") {
text = "@startuml\n" + text
}
if !strings.HasSuffix(text, "@enduml") {
text = text + "\n@enduml"
}
encoded, err := p.encodePlantUML(text)
if err != nil {
return "", fmt.Errorf("failed to encode PlantUML: %w", err)
}
url := fmt.Sprintf("%s/svg/%s", p.serverURL, encoded)
resp, err := p.client.Get(url)
if err != nil {
return "", fmt.Errorf("failed to request diagram: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("PlantUML server returned status %d", resp.StatusCode)
}
var svgBuffer bytes.Buffer
_, err = io.Copy(&svgBuffer, resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read SVG response: %w", err)
}
svg := svgBuffer.String()
p.mutex.Lock()
p.cache[key] = cacheEntry{
svg: svg,
timestamp: time.Now(),
}
p.mutex.Unlock()
return svg, nil
}
// CleanCache removes old entries from cache
func (p *PlantUMLProcessor) CleanCache() {
p.mutex.Lock()
defer p.mutex.Unlock()
now := time.Now()
for key, entry := range p.cache {
if now.Sub(entry.timestamp) > 24*time.Hour {
delete(p.cache, key)
}
}
}
// MermaidProcessor handles Mermaid diagram preparation
type MermaidProcessor struct {
diagramCounter int
mutex sync.Mutex
}
// NewMermaidProcessor creates a new Mermaid processor
func NewMermaidProcessor() *MermaidProcessor {
return &MermaidProcessor{
diagramCounter: 0,
}
}
// PrepareHTML wraps Mermaid diagram text in appropriate HTML
func (m *MermaidProcessor) PrepareHTML(mermaidText string) string {
m.mutex.Lock()
m.diagramCounter++
counter := m.diagramCounter
m.mutex.Unlock()
escaped := html.EscapeString(mermaidText)
return fmt.Sprintf(
"<div class=\"mermaid\" id=\"mermaid-%d\">\n%s\n</div>",
counter,
escaped,
)
}
// DiagramRenderer is a custom renderer for diagram code blocks
type DiagramRenderer struct {
plantUML *PlantUMLProcessor
mermaid *MermaidProcessor
}
// NewDiagramRenderer creates a new diagram renderer
func NewDiagramRenderer(plantUMLServer string) *DiagramRenderer {
return &DiagramRenderer{
plantUML: NewPlantUMLProcessor(plantUMLServer),
mermaid: NewMermaidProcessor(),
}
}
// RegisterFuncs registers the rendering functions
func (r *DiagramRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock)
}
// renderFencedCodeBlock handles rendering of fenced code blocks
func (r *DiagramRenderer) renderFencedCodeBlock(
w util.BufWriter,
source []byte,
node ast.Node,
entering bool,
) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.FencedCodeBlock)
language := string(n.Language(source))
var content bytes.Buffer
lines := n.Lines()
for i := 0; i < lines.Len(); i++ {
line := lines.At(i)
content.Write(line.Value(source))
}
codeText := content.String()
switch language {
case "plantuml":
svg, err := r.plantUML.GenerateSVG(codeText)
if err != nil {
w.WriteString(fmt.Sprintf(
"<div class=\"error\">PlantUML Error: %s</div>\n<pre><code>%s</code></pre>",
html.EscapeString(err.Error()),
html.EscapeString(codeText),
))
} else {
w.WriteString(svg)
}
return ast.WalkContinue, nil
case "mermaid":
htmlContent := r.mermaid.PrepareHTML(codeText)
w.WriteString(htmlContent)
return ast.WalkContinue, nil
default:
// Return WalkSkipChildren to let default renderer handle it
return ast.WalkSkipChildren, nil
}
}
// MarkdownProcessor processes Markdown with diagram support
type MarkdownProcessor struct {
md goldmark.Markdown
diagramRenderer *DiagramRenderer
}
// NewMarkdownProcessor creates a processor with full diagram support
func NewMarkdownProcessor(plantUMLServer string) *MarkdownProcessor {
diagramRenderer := NewDiagramRenderer(plantUMLServer)
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
extension.Table,
extension.Strikethrough,
extension.TaskList,
extension.Linkify,
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
parser.WithAttribute(),
),
goldmark.WithRendererOptions(
gmhtml.WithHardWraps(),
gmhtml.WithXHTML(),
gmhtml.WithUnsafe(),
),
goldmark.WithRenderer(
renderer.NewRenderer(
renderer.WithNodeRenderers(
util.Prioritized(diagramRenderer, 100),
),
),
),
)
return &MarkdownProcessor{
md: md,
diagramRenderer: diagramRenderer,
}
}
// Process converts Markdown text to HTML with diagram support
func (p *MarkdownProcessor) Process(markdown string) (string, error) {
var buf bytes.Buffer
err := p.md.Convert([]byte(markdown), &buf)
if err != nil {
return "", err
}
return buf.String(), nil
}
// RateLimiter implements simple rate limiting with automatic cleanup
type RateLimiter struct {
requests map[string][]time.Time
mutex sync.Mutex
limit int
window time.Duration
lastCleanup time.Time
cleanupPeriod time.Duration
}
// NewRateLimiter creates a new rate limiter
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
return &RateLimiter{
requests: make(map[string][]time.Time),
limit: limit,
window: window,
lastCleanup: time.Now(),
cleanupPeriod: 10 * time.Minute,
}
}
// Allow checks if a request should be allowed
func (rl *RateLimiter) Allow(ip string) bool {
rl.mutex.Lock()
defer rl.mutex.Unlock()
now := time.Now()
cutoff := now.Add(-rl.window)
// Periodic cleanup of old entries
if now.Sub(rl.lastCleanup) > rl.cleanupPeriod {
rl.cleanup(cutoff)
rl.lastCleanup = now
}
requests := rl.requests[ip]
validRequests := make([]time.Time, 0)
for _, t := range requests {
if t.After(cutoff) {
validRequests = append(validRequests, t)
}
}
if len(validRequests) >= rl.limit {
return false
}
validRequests = append(validRequests, now)
rl.requests[ip] = validRequests
return true
}
// cleanup removes old entries from the rate limiter map
func (rl *RateLimiter) cleanup(cutoff time.Time) {
for ip, requests := range rl.requests {
validRequests := make([]time.Time, 0)
for _, t := range requests {
if t.After(cutoff) {
validRequests = append(validRequests, t)
}
}
if len(validRequests) == 0 {
delete(rl.requests, ip)
} else {
rl.requests[ip] = validRequests
}
}
}
// Middleware returns an HTTP middleware for rate limiting
func (rl *RateLimiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
if !rl.Allow(ip) {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
// MarkdownHandler handles Markdown processing requests
type MarkdownHandler struct {
processor *MarkdownProcessor
logger *log.Logger
}
// NewMarkdownHandler creates a new handler
func NewMarkdownHandler(plantUMLServer string, logger *log.Logger) *MarkdownHandler {
processor := NewMarkdownProcessor(plantUMLServer)
return &MarkdownHandler{
processor: processor,
logger: logger,
}
}
// ServeHTTP handles the main page
func (h *MarkdownHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
h.showForm(w, r)
return
}
if r.Method == http.MethodPost {
h.processMarkdown(w, r)
return
}
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
// showForm displays the upload form
func (h *MarkdownHandler) showForm(w http.ResponseWriter, r *http.Request) {
formHTML := `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Markdown Processor</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 {
color: #333;
margin-bottom: 30px;
}
textarea {
width: 100%;
min-height: 300px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 4px;
font-family: "Courier New", monospace;
font-size: 14px;
resize: vertical;
box-sizing: border-box;
}
.file-input {
margin: 20px 0;
}
button {
background-color: #007bff;
color: white;
padding: 12px 30px;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.2s;
}
button:hover {
background-color: #0056b3;
}
.or-divider {
text-align: center;
margin: 20px 0;
color: #666;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
</style>
</head>
<body>
<div class="container">
<h1>Markdown Processor with Diagram Support</h1>
<form method="POST" enctype="multipart/form-data">
<div>
<label for="markdown_text">Enter Markdown Text:</label>
<textarea name="markdown_text" id="markdown_text"
placeholder="Type your Markdown here... You can use PlantUML diagrams: ```plantuml @startuml Alice -> Bob: Hello @enduml ``` Or Mermaid diagrams: ```mermaid graph TD A[Start] --> B[End] ```"></textarea>
</div>
<div class="or-divider">OR</div>
<div class="file-input">
<label for="markdown_file">Upload Markdown File:</label>
<input type="file" name="markdown_file" id="markdown_file"
accept=".md,.markdown,.txt">
</div>
<button type="submit">Process Markdown</button>
</form>
</div>
</body>
</html>`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(formHTML))
}
// validateMarkdownFile checks if uploaded file is valid
func validateMarkdownFile(filename string) error {
ext := strings.ToLower(filepath.Ext(filename))
allowedExtensions := map[string]bool{
".md": true,
".markdown": true,
".txt": true,
}
if !allowedExtensions[ext] {
return fmt.Errorf("invalid file extension: %s", ext)
}
return nil
}
// processMarkdown handles Markdown file upload and processing
func (h *MarkdownHandler) processMarkdown(w http.ResponseWriter, r *http.Request) {
// Check content length before parsing
if r.ContentLength > 10<<20 { // 10 MB
http.Error(w, "Request too large", http.StatusRequestEntityTooLarge)
return
}
err := r.ParseMultipartForm(10 << 20)
if err != nil {
h.logger.Printf("Failed to parse form: %v", err)
http.Error(w, "Failed to parse form", http.StatusBadRequest)
return
}
textContent := r.FormValue("markdown_text")
var markdownContent string
if textContent != "" {
// Validate text content length
if len(textContent) > 10<<20 {
http.Error(w, "Text content too large", http.StatusRequestEntityTooLarge)
return
}
markdownContent = textContent
} else {
file, header, err := r.FormFile("markdown_file")
if err != nil {
http.Error(w, "No Markdown content provided", http.StatusBadRequest)
return
}
defer file.Close()
if err := validateMarkdownFile(header.Filename); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Validate file size
if header.Size > 10<<20 {
http.Error(w, "File too large", http.StatusRequestEntityTooLarge)
return
}
content, err := io.ReadAll(io.LimitReader(file, 10<<20+1))
if err != nil {
h.logger.Printf("Failed to read file: %v", err)
http.Error(w, "Failed to read file", http.StatusInternalServerError)
return
}
if len(content) > 10<<20 {
http.Error(w, "File content too large", http.StatusRequestEntityTooLarge)
return
}
markdownContent = string(content)
}
htmlContent, err := h.processor.Process(markdownContent)
if err != nil {
h.logger.Printf("Processing error: %v", err)
http.Error(w, fmt.Sprintf("Processing error: %s", err.Error()),
http.StatusInternalServerError)
return
}
// Escape the HTML content for safe embedding
escapedHTML := strings.ReplaceAll(htmlContent, "`", "`")
resultHTML := `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Processed Markdown</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
line-height: 1.6;
}
.container {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.content {
color: #333;
}
.content h1, .content h2, .content h3 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
.content h1 {
font-size: 2em;
border-bottom: 1px solid #eee;
padding-bottom: 0.3em;
}
.content h2 {
font-size: 1.5em;
border-bottom: 1px solid #eee;
padding-bottom: 0.3em;
}
.content h3 {
font-size: 1.25em;
}
.content code {
background-color: #f6f8fa;
padding: 2px 6px;
border-radius: 3px;
font-family: "Courier New", monospace;
font-size: 85%;
}
.content pre {
background-color: #f6f8fa;
padding: 16px;
border-radius: 6px;
overflow-x: auto;
}
.content pre code {
background-color: transparent;
padding: 0;
}
.content table {
border-collapse: collapse;
width: 100%;
margin: 16px 0;
}
.content table th,
.content table td {
padding: 8px 13px;
border: 1px solid #ddd;
}
.content table th {
background-color: #f6f8fa;
font-weight: 600;
}
.content table tr:nth-child(even) {
background-color: #f6f8fa;
}
.content img,
.content svg {
max-width: 100%;
height: auto;
}
.content blockquote {
border-left: 4px solid #ddd;
padding-left: 16px;
color: #666;
margin: 16px 0;
}
.content ul, .content ol {
padding-left: 2em;
}
.content li {
margin: 4px 0;
}
.mermaid {
text-align: center;
margin: 20px 0;
}
.error {
background-color: #fff3cd;
border: 1px solid #ffc107;
padding: 12px;
border-radius: 4px;
margin: 16px 0;
color: #856404;
}
.back-button {
display: inline-block;
margin-bottom: 20px;
padding: 10px 20px;
background-color: #6c757d;
color: white;
text-decoration: none;
border-radius: 4px;
transition: background-color 0.2s;
}
.back-button:hover {
background-color: #5a6268;
}
</style>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'default',
securityLevel: 'loose'
});
</script>
</head>
<body>
<div class="container">
<a href="/" class="back-button">Process Another Document</a>
<div class="content">
` + escapedHTML + `
</div>
</div>
</body>
</html>`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(resultHTML))
}
// LoggingMiddleware logs all HTTP requests
func LoggingMiddleware(logger *log.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger.Printf("%s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
next.ServeHTTP(w, r)
})
}
func main() {
cfg := LoadConfiguration()
logger := log.New(os.Stdout, "[MarkdownProcessor] ", log.LstdFlags)
logger.Printf("Starting Markdown Processor")
logger.Printf("PlantUML Server: %s", cfg.PlantUMLServer)
logger.Printf("Server Port: %s", cfg.ServerPort)
markdownHandler := NewMarkdownHandler(cfg.PlantUMLServer, logger)
rateLimiter := NewRateLimiter(100, time.Minute)
// Start cache cleanup goroutine
stopCleanup := make(chan struct{})
go func() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
markdownHandler.processor.diagramRenderer.plantUML.CleanCache()
logger.Printf("Cache cleaned")
case <-stopCleanup:
return
}
}
}()
mux := http.NewServeMux()
mux.Handle("/", rateLimiter.Middleware(LoggingMiddleware(logger, markdownHandler)))
server := &http.Server{
Addr: fmt.Sprintf(":%s", cfg.ServerPort),
Handler: mux,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Channel to listen for errors coming from the listener
serverErrors := make(chan error, 1)
// Start the server
go func() {
logger.Printf("Server listening on %s", server.Addr)
serverErrors <- server.ListenAndServe()
}()
// Channel to listen for interrupt signal to terminate
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)
// Block until we receive a signal or an error
select {
case err := <-serverErrors:
logger.Fatalf("Error starting server: %v", err)
case sig := <-shutdown:
logger.Printf("Received signal %v, starting graceful shutdown", sig)
// Stop the cache cleanup goroutine
close(stopCleanup)
// Give outstanding requests a deadline for completion
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// Asking listener to shutdown
if err := server.Shutdown(ctx); err != nil {
logger.Printf("Graceful shutdown failed: %v", err)
if err := server.Close(); err != nil {
logger.Fatalf("Could not stop server: %v", err)
}
}
logger.Printf("Server stopped gracefully")
}
}
This complete implementation provides a fully functional web-based Markdown processor with PlantUML and Mermaid support. The code includes proper error handling, caching, rate limiting, security measures, and logging. It follows clean code principles with clear separation of concerns and comprehensive documentation.
To run this application, you need to install the goldmark dependency using the Go module system. Create a go.mod file in your project directory and run the following commands in your terminal:
go mod init markdownprocessor
go get github.com/yuin/goldmark
go get github.com/yuin/goldmark/extension
Then compile and run the application:
go build -o markdown-processor
./markdown-processor
The server will start on port 8080 by default. You can customize the port and PlantUML server URL using environment variables:
export SERVER_PORT=3000
export PLANTUML_SERVER=http://localhost:8080
./markdown-processor
The application provides a web interface where users can either paste Markdown text directly or upload Markdown files. The processor handles all standard Markdown syntax including headings, lists, tables, code blocks, and links. When it encounters PlantUML or Mermaid diagram blocks, it processes them appropriately, generating SVG images for PlantUML and preparing HTML for client-side Mermaid rendering.
The caching system ensures that identical PlantUML diagrams are only generated once, significantly improving performance for documents with repeated diagrams. The rate limiter prevents abuse by limiting requests from individual IP addresses. The logging system provides visibility into application behavior for debugging and monitoring.
This implementation represents a production-ready solution that can be deployed to handle real-world documentation needs. The architecture is extensible, allowing for easy addition of new diagram types or other Markdown extensions. The clean separation of concerns makes the codebase maintainable and testable.