Tuesday, November 10, 2026

BUILDING A WEB-BASED MARKDOWN PROCESSOR IN GO WITH PLANTUML AND MERMAID SUPPORT




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...&#10;&#10;You can use PlantUML diagrams:&#10;&#96;&#96;&#96;plantuml&#10;@startuml&#10;Alice -> Bob: Hello&#10;@enduml&#10;&#96;&#96;&#96;&#10;&#10;Or Mermaid diagrams:&#10;&#96;&#96;&#96;mermaid&#10;graph TD&#10;A[Start] --> B[End]&#10;&#96;&#96;&#96;"></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, "`", "&#96;")


    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.

No comments: