Documents
Markdown Builder Pattern
Markdown Builder Pattern
Type
Topic
Status
Published
Created
Feb 27, 2026
Updated
May 4, 2026
Created by
Dosu Bot
Updated by
Dosu Bot

Markdown Builder Pattern#

The Markdown Builder Pattern is a software architecture that combines streaming io.Writer support alongside string-based APIs using the nao1215/markdown library with fluent builder patterns. Implemented in the opnDossier project, this pattern enables type-safe, compile-time-guaranteed markdown document generation for both in-memory processing and streaming use cases.

The pattern addresses a common challenge in document generation systems: providing flexible APIs that support both simple string-based operations and memory-efficient streaming operations. By employing interface embedding and separate writer interfaces, the architecture allows consumers to choose the appropriate method based on their specific requirements, such as HTML conversion workflows that benefit from streaming.

This architectural pattern leverages the nao1215/markdown library's fluent builder API to ensure proper markdown escaping, formatting consistency, and prevention of syntax errors through programmatic generation rather than template-based approaches.

Core Architecture#

ReportBuilder Interface Structure#

The ReportBuilder interface is a composite interface that brings together three focused sub-interfaces following the Interface Segregation Principle (ISP):

  • SectionBuilder (9 methods): Methods for building individual report sections (BuildSystemSection, BuildNetworkSection, BuildSecuritySection, BuildServicesSection, BuildIPsecSection, BuildOpenVPNSection, BuildHASection, BuildIDSSection, BuildAuditSection) and the SetIncludeTunables setter.
  • TableWriter (11 methods): Methods for writing data tables into markdown instances (WriteFirewallRulesTable, WriteInterfaceTable, WriteStaticRoutesTable, WriteSystemTunablesTable, WriteAliasesTable, WriteNATRulesTable, WriteVIPTable, WriteIPsecConnectionsTable, WriteOpenVPNInstancesTable, WriteDHCPSummaryTable, WriteDHCPStaticLeasesTable).
  • ReportComposer (2 methods): Methods for composing complete reports (BuildStandardReport, BuildComprehensiveReport).

ReportBuilder composes all three interfaces to provide full backward compatibility:

// ReportBuilder defines the contract for programmatic report generation.
// It composes SectionBuilder, TableWriter, and ReportComposer to provide
// type-safe, compile-time guaranteed markdown generation.
type ReportBuilder interface {
    SectionBuilder
    TableWriter
    ReportComposer
}

// Compile-time assertion that MarkdownBuilder satisfies ReportBuilder.
var _ ReportBuilder = (*MarkdownBuilder)(nil)

This refactoring implements Interface Segregation Principle (ISP) and closed issue #323 while maintaining full backward compatibility. The MarkdownBuilder struct continues to implement all methods, and consumers referencing ReportBuilder see no breaking changes.

Consumer-Local Interface Narrowing#

HybridGenerator demonstrates the consumer-local interface narrowing pattern by declaring private interfaces (reportGenerator, auditBuilder) that expose only the subset of methods it actually calls:

// reportGenerator is the narrowest interface HybridGenerator requires from its
// builder. It exposes only the four methods HybridGenerator directly calls:
// SetIncludeTunables, BuildAuditSection (via auditBuilder), BuildStandardReport,
// and BuildComprehensiveReport (via ReportComposer).
// SectionBuilder and TableWriter are deliberately excluded — HybridGenerator
// never calls individual section or table methods.
type reportGenerator interface {
    auditBuilder
    builder.ReportComposer
}

// auditBuilder groups the two non-report-composition methods HybridGenerator calls.
type auditBuilder interface {
    // SetIncludeTunables configures whether all system tunables are included in the report.
    SetIncludeTunables(v bool)
    // BuildAuditSection builds the compliance audit section from the device's ComplianceResults.
    BuildAuditSection(data *common.CommonDevice) string
}

This pattern keeps dependency declarations narrow and explicit, making it clear which methods a consumer actually uses. The public constructor/setter methods continue to accept the broad ReportBuilder interface for backward compatibility.

GetBuilder() uses a two-value type assertion to safely recover the full ReportBuilder interface when needed:

// GetBuilder returns the current report builder as a ReportBuilder.
// The underlying value is always a ReportBuilder (e.g., *MarkdownBuilder),
// so the type assertion succeeds in practice. Returns nil if the builder
// is nil or does not satisfy ReportBuilder.
func (g *HybridGenerator) GetBuilder() builder.ReportBuilder {
    if g.builder == nil {
        return nil
    }

    rb, ok := g.builder.(builder.ReportBuilder)
    if !ok {
        return nil
    }

    return rb
}

Dual Interface Pattern#

The pattern separates concerns by defining base interfaces for string-based generation alongside streaming interfaces that embed and extend them.

Base Generator Interface#

The base Generator interface provides synchronous string-based generation:

type Generator interface {
    // Generate creates documentation in a specified format from the provided device configuration.
    // This method returns the complete output as a string, which is useful when the output
    // needs further processing (e.g., HTML conversion).
    Generate(ctx context.Context, cfg *common.CommonDevice, opts Options) (string, error)
}

Streaming Generator Interface#

The StreamingGenerator interface extends Generator via interface embedding:

type StreamingGenerator interface {
    Generator // Embeds base interface

    // GenerateToWriter writes documentation directly to the provided io.Writer.
    // This is more memory-efficient than Generate() for large configurations
    // as it streams output section-by-section.
    GenerateToWriter(ctx context.Context, w io.Writer, cfg *common.CommonDevice, opts Options) error
}

By embedding the Generator interface, StreamingGenerator implementations automatically inherit the Generate() method while adding streaming capability through GenerateToWriter().

Interface Embedding Strategy#

The HybridGenerator struct implements both interfaces. Its builder field is internally typed as reportGenerator, a narrower consumer-local interface, while the public API methods SetBuilder() and GetBuilder() continue to work with the full ReportBuilder interface for backward compatibility:

type HybridGenerator struct {
    builder reportGenerator // Narrowed to consumer-local interface
    logger *logging.Logger
}

// Ensure HybridGenerator implements StreamingGenerator at compile time
var _ StreamingGenerator = (*HybridGenerator)(nil)

The implementation uses type assertion at runtime to check if the underlying builder supports streaming:

func (g *HybridGenerator) generateMarkdownToWriter(w io.Writer, data *common.CommonDevice, opts Options) error {
    // Check if builder supports SectionWriter interface for streaming
    sectionWriter, ok := g.builder.(builder.SectionWriter)
    if !ok {
        // Fallback to string-based generation if builder doesn't support streaming
        g.logger.Debug("Builder does not support SectionWriter, falling back to string generation")
        output, err := g.generateMarkdown(data, opts)
        if err != nil {
            return err
        }
        _, err = io.WriteString(w, output)
        return err
    }

    // Use streaming writer
    switch {
    case opts.Comprehensive:
        return sectionWriter.WriteComprehensiveReport(w, data)
    default:
        return sectionWriter.WriteStandardReport(w, data)
    }
}

Separate Writer Interfaces#

The SectionWriter interface defines the contract for streaming report generation:

type SectionWriter interface {
    // WriteSystemSection writes the system configuration section to the writer.
    WriteSystemSection(w io.Writer, data *common.CommonDevice) error

    // WriteNetworkSection writes the network configuration section to the writer.
    WriteNetworkSection(w io.Writer, data *common.CommonDevice) error

    // WriteSecuritySection writes the security configuration section to the writer.
    WriteSecuritySection(w io.Writer, data *common.CommonDevice) error

    // WriteServicesSection writes the services configuration section to the writer.
    WriteServicesSection(w io.Writer, data *common.CommonDevice) error

    // WriteAuditSection writes the compliance audit section to the writer.
    WriteAuditSection(w io.Writer, data *common.CommonDevice) error

    // WriteStandardReport writes a complete standard report to the writer.
    WriteStandardReport(w io.Writer, data *common.CommonDevice) error

    // WriteComprehensiveReport writes a complete comprehensive report to the writer.
    WriteComprehensiveReport(w io.Writer, data *common.CommonDevice) error
}

Simple section writers act as adapters, delegating to existing Build methods:

func (b *MarkdownBuilder) WriteSystemSection(w io.Writer, data *common.CommonDevice) error {
    section := b.BuildSystemSection(data)
    _, err := io.WriteString(w, section)
    return err
}

func (b *MarkdownBuilder) WriteAuditSection(w io.Writer, data *common.CommonDevice) error {
    section := b.BuildAuditSection(data)
    _, err := io.WriteString(w, section)
    return err
}

Markdown Generation Using nao1215/markdown#

Fluent Builder Pattern#

The nao1215/markdown library provides a fluent builder API where methods return *markdown.Markdown to enable method chaining. The basic pattern creates a markdown builder with a buffer:

var buf bytes.Buffer
md := markdown.NewMarkdown(&buf)

Complex method chaining example:

md := markdown.NewMarkdown(&buf).
    H1("OPNsense Configuration Summary").
    H2("System Information").
    BulletList(
        markdown.Bold("Hostname")+": "+data.System.Hostname,
        markdown.Bold("Domain")+": "+data.System.Domain,
        markdown.Bold("Platform")+": OPNsense "+data.System.Firmware.Version,
        markdown.Bold("Generated On")+": "+b.generated.Format(time.RFC3339),
        markdown.Bold("Parsed By")+": opnDossier v"+b.toolVersion,
    ).
    H2("Table of Contents").
    BulletList(
        markdown.Link("System Configuration", "#system-configuration"),
        markdown.Link("Interfaces", "#interfaces"),
        markdown.Link("Firewall Rules", "#firewall-rules"),
        markdown.Link("NAT Configuration", "#nat-configuration"),
    )

Chaining with PlainTextf and LF (line feed):

md.H2("System Configuration").
    H3("Basic Information").
    PlainTextf("%s: %s", markdown.Bold("Hostname"), sys.Hostname).LF().
    PlainTextf("%s: %s", markdown.Bold("Domain"), sys.Domain).LF()

Library Methods vs Manual String Construction#

Best practice: Always use library methods over manual string concatenation. The library ensures proper markdown escaping, formatting consistency, and prevents syntax errors:

// ✓ CORRECT: Use library methods
md.BoldText("Important").Code("config.xml")

// ✗ AVOID: Manual string construction
result := "**Important** `config.xml`" // Loses escaping, error-prone

Semantic Alerts#

The library supports semantic alert blocks:

md.Warning(
    "NAT reflection is enabled, which may allow internal clients to access "+
    "internal services via external IP addresses. Consider disabling if not needed.",
)

md.Note(
    "NAT reflection is properly disabled, preventing potential security issues.",
)

md.Tip(
    "Consider enabling IPS mode for active threat prevention. "+
    "IDS mode only detects threats without blocking them.",
)

Inline Formatting Helpers#

Helper functions provide safe inline formatting:

markdown.Bold("text") // **text**
markdown.Italic("text") // *text*
markdown.Code("code") // `code`
markdown.Link("label", "https://url") // [label](https://url)

Example usage in formatted strings:

configItems := []string{
    fmt.Sprintf("%s: %s", markdown.Bold("Hostname"), r.ConfigInfo.Hostname),
    fmt.Sprintf("%s: %s", markdown.Bold("Domain"), r.ConfigInfo.Domain),
}

Table Construction#

Tables use the TableSet structure with headers and rows:

headers := []string{"Tunable", "Value", "Description"}

rows := make([][]string, 0, len(data.Sysctl))
for _, item := range data.Sysctl {
    rows = append(rows, []string{item.Tunable, item.Value, item.Description})
}

tableSet := markdown.TableSet{
    Header: headers,
    Rows: rows,
}
md.Table(tableSet)

Tables can be chained with header methods:

func (b *MarkdownBuilder) WriteFirewallRulesTable(
    md *markdown.Markdown,
    rules []common.FirewallRule,
) *markdown.Markdown {
    return md.Table(*BuildFirewallRulesTableSet(rules))
}

// Usage:
b.WriteFirewallRulesTable(md.H3("Firewall Rules"), data.FirewallRules)

Inline table creation:

md.H4("General Configuration").
    Table(markdown.TableSet{
        Header: []string{"Setting", "Value"},
        Rows: [][]string{
            {"**Enabled**", formatters.FormatBool(ipsec.Enabled)},
        },
    })

BulletList items can contain formatted content including links:

tocItems := []string{
    markdown.Link("System Configuration", "#system-configuration"),
    markdown.Link("Interfaces", "#interfaces"),
}
md.BulletList(tocItems...)

Helper function for generating navigation links:

func FormatInterfacesAsLinks(ifaces []string) string {
    if len(ifaces) == 0 {
        return ""
    }
    var buf strings.Builder
    buf.WriteString("[")
    buf.WriteString(ifaces[0])
    buf.WriteString("](#")
    buf.WriteString(ifaces[0])
    buf.WriteString("-interface)")
    for _, iface := range ifaces[1:] {
        buf.WriteString(", [")
        buf.WriteString(iface)
        buf.WriteString("](#")
        buf.WriteString(iface)
        buf.WriteString("-interface)")
    }
    return buf.String()
}

The function uses strings.Builder for performance optimization (41% allocation reduction in firewall table benchmarks) rather than the nao1215/markdown library. Output is byte-identical to the prior implementation.

Escaping and Truncation Helpers#

The builder package provides specialized helper functions for table content safety and display formatting:

// EscapePipeForMarkdown escapes only pipe characters for table cell safety
func EscapePipeForMarkdown(s string) string {
    return strings.ReplaceAll(s, "|", "\\|")
}

// TruncateString truncates at exact rune positions (rune-aware)
func TruncateString(s string, maxLen int) string {
    runes := []rune(s)
    if len(runes) <= maxLen {
        return s
    }
    if maxLen <= 3 {
        return string(runes[:maxLen])
    }
    return string(runes[:maxLen-3]) + "..."
}

These helpers differ from formatters package utilities:

  • EscapePipeForMarkdown() escapes only pipes, unlike formatters.EscapeTableContent() which escapes all markdown special characters
  • TruncateString() truncates at exact rune positions, unlike formatters.TruncateDescription() which truncates at word boundaries

Usage Guidelines#

When to Use String-Based Methods#

String-based methods like Generate() are appropriate for:

When to Use Streaming Methods#

Streaming methods like GenerateToWriter() are appropriate for:

  • Large documents where memory efficiency is critical
  • File operations where writing directly to disk reduces memory pressure
  • Real-time output where you want to stream progress to the user
  • Multi-format export where the same document is transformed to multiple formats
  • Network transmission where chunked delivery is beneficial

Best Practices#

1. Type Safety#

The programmatic approach delivers type safety compared to template-based generation:

// Type-safe: Compiler catches errors
func (b *MarkdownBuilder) BuildSystemSection(data *common.CommonDevice) string

// Type-unsafe: Runtime errors possible
func (b *MarkdownBuilder) Build(data interface{}) string

2. Library Methods Over Manual Construction#

Always use the library's fluent methods to ensure proper escaping and formatting:

// ✓ CORRECT
md.Table(markdown.TableSet{Header: headers, Rows: rows})
md.BulletList(markdown.Link("text", "url"))

// ✗ AVOID
md.PlainText("- [text](url)\n") // Manual markdown syntax

3. Separation of Concerns#

Define clear boundaries between generation logic, calculation logic, and data transformation:

type ReportGenerator struct {
    calculator SecurityCalculator
    transformer DataTransformer
    formatter StringFormatter
}

// Each component has a single responsibility

4. Performance Optimization#

For large datasets, pre-allocate slices with estimated capacity:

// Pre-allocate for efficiency
rows := make([][]string, 0, len(rules))
for _, rule := range rules {
    rows = append(rows, []string{rule.Name, rule.Action})
}

5. Error Handling#

Use sentinel errors and context wrapping for clear error chains:

var ErrNilDevice = errors.New("device configuration is nil")

func (b *MarkdownBuilder) WriteStandardReport(w io.Writer, data *common.CommonDevice) error {
    if data == nil {
        return ErrNilDevice
    }

    if err := b.writeReportHeader(w, data); err != nil {
        return fmt.Errorf("failed to write report header: %w", err)
    }

    // ... more sections
    return nil
}

Code Examples#

Complete Streaming Report Generation#

Example from WriteStandardReport:

func (b *MarkdownBuilder) WriteStandardReport(w io.Writer, data *common.CommonDevice) error {
    if data == nil {
        return ErrNilDevice
    }

    // Write header section
    if err := b.writeReportHeader(w, data); err != nil {
        return fmt.Errorf("failed to write report header: %w", err)
    }

    // Write table of contents
    if err := b.writeTableOfContents(w, false); err != nil {
        return fmt.Errorf("failed to write table of contents: %w", err)
    }

    // Write each section directly - no intermediate string accumulation
    if err := b.WriteSystemSection(w, data); err != nil {
        return fmt.Errorf("failed to write system section: %w", err)
    }

    if err := b.WriteNetworkSection(w, data); err != nil {
        return fmt.Errorf("failed to write network section: %w", err)
    }

    if err := b.WriteSecuritySection(w, data); err != nil {
        return fmt.Errorf("failed to write security section: %w", err)
    }

    if err := b.WriteServicesSection(w, data); err != nil {
        return fmt.Errorf("failed to write services section: %w", err)
    }

    return nil
}

Hybrid Generator with Fallback Pattern#

Example showing runtime type assertion and graceful fallback:

func (g *HybridGenerator) generateMarkdownToWriter(
    w io.Writer,
    data *common.CommonDevice,
    opts Options,
) error {
    g.logger.Debug("Using streaming markdown generation")

    if g.builder == nil {
        return errors.New("no report builder available")
    }

    // Check if builder supports SectionWriter interface for streaming
    sectionWriter, ok := g.builder.(builder.SectionWriter)
    if !ok {
        // Fallback to string-based generation
        g.logger.Debug("Builder does not support SectionWriter, falling back")
        output, err := g.generateMarkdown(data, opts)
        if err != nil {
            return err
        }
        _, err = io.WriteString(w, output)
        return err
    }

    // Use streaming writer
    switch {
    case opts.Comprehensive:
        return sectionWriter.WriteComprehensiveReport(w, data)
    default:
        return sectionWriter.WriteStandardReport(w, data)
    }
}

Building Complex Tables#

Example firewall rules table with formatting:

func BuildFirewallRulesTableSet(rules []common.FirewallRule) *markdown.TableSet {
    headers := []string{
        "#", "Interface", "Action", "IP Ver", "Proto",
        "Source", "Destination", "Target", "Source Port",
        "Dest Port", "Enabled", "Description",
    }

    rows := make([][]string, 0, len(rules))
    for i, rule := range rules {
        source := rule.Source.Address
        if source == "" {
            source = "any"
        }

        dest := rule.Destination.Address
        if dest == "" {
            dest = "any"
        }

        interfaceLinks := formatters.FormatInterfacesAsLinks(rule.Interfaces)

        rows = append(rows, []string{
            strconv.Itoa(i + 1),
            interfaceLinks,
            rule.Type,
            rule.IPProtocol,
            rule.Protocol,
            source,
            dest,
            rule.Target,
            formatters.EscapeTableContent(rule.Source.Port),
            formatters.EscapeTableContent(rule.Destination.Port),
            formatters.FormatBoolInverted(rule.Disabled),
            formatters.EscapeTableContent(rule.Description),
        })
    }

    return &markdown.TableSet{
        Header: headers,
        Rows: rows,
    }
}

Compliance Audit Section Builder#

The BuildAuditSection() and WriteAuditSection() methods render compliance audit results from CommonDevice.ComplianceResults:

// BuildAuditSection builds the compliance audit section from the device's ComplianceResults.
// If ComplianceResults is nil, it returns an empty string.
func (b *MarkdownBuilder) BuildAuditSection(data *common.CommonDevice) string {
    if data.ComplianceResults == nil {
        return ""
    }

    cc := data.ComplianceResults
    var buf bytes.Buffer
    md := markdown.NewMarkdown(&buf)

    // Summary table
    md.H2("Compliance Audit Summary")
    md.Table(markdown.TableSet{
        Header: []string{"Metric", "Value"},
        Rows: [][]string{
            {"Mode", cc.Mode},
            {"Total Findings", strconv.Itoa(totalFindings)},
        },
    })

    // Plugin compliance results
    if len(cc.PluginResults) > 0 {
        md.H3("Plugin Compliance Results")
        for _, pluginName := range slices.Sorted(maps.Keys(cc.PluginResults)) {
            result := cc.PluginResults[pluginName]
            // ... render plugin summary
        }
    }

    // Security findings table
    if len(cc.Findings) > 0 {
        md.H3("Security Findings")
        findingsTable := markdown.TableSet{
            Header: []string{"Severity", "Component", "Title", "Recommendation"},
            Rows: make([][]string, 0, len(cc.Findings)),
        }
        for _, f := range cc.Findings {
            findingsTable.Rows = append(findingsTable.Rows, []string{
                EscapePipeForMarkdown(f.Severity),
                EscapePipeForMarkdown(f.Component),
                EscapePipeForMarkdown(f.Title),
                EscapePipeForMarkdown(f.Recommendation),
            })
        }
        md.Table(findingsTable)
    }

    return buf.String()
}

Key implementation details:

  • Returns empty string when ComplianceResults is nil, allowing unconditional calls
  • Uses EscapePipeForMarkdown() for table cell content (pipe-only escaping)
  • Uses TruncateString() for rune-aware truncation at exact character positions
  • Iterates plugin names and metadata keys in sorted order for deterministic output
  • Renders findings in markdown table format with severity, component, title, and recommendation columns

The HybridGenerator conditionally appends audit sections when compliance data is present:

// String-based generation
report, err := g.builder.BuildStandardReport(target)
if err != nil {
    return "", err
}

if target.ComplianceResults != nil {
    auditSection := g.builder.BuildAuditSection(target)
    if auditSection != "" {
        report += "\n\n" + auditSection
    }
}

return report, nil
// Streaming generation
err := sectionWriter.WriteStandardReport(w, target)
if err != nil {
    return err
}

if target.ComplianceResults != nil {
    if _, writeErr := io.WriteString(w, "\n\n"); writeErr != nil {
        return fmt.Errorf("failed to write audit section separator: %w", writeErr)
    }
    if writeErr := sectionWriter.WriteAuditSection(w, target); writeErr != nil {
        return fmt.Errorf("failed to write audit section: %w", writeErr)
    }
}

Relevant Code Files#

File PathDescription
internal/converter/hybrid_generator.goImplements the dual API pattern with Generator and StreamingGenerator interfaces, consumer-local interface narrowing (reportGenerator, auditBuilder), runtime type assertion, graceful fallback, and conditional audit section appending
internal/converter/builder/writer.goDefines SectionWriter interface and implements streaming report generation methods that write directly to io.Writer, including WriteAuditSection()
internal/converter/builder/builder.goContains MarkdownBuilder struct, three focused interfaces (SectionBuilder, TableWriter, ReportComposer) that compose into ReportBuilder, string-based section building methods, and BuildAuditSection() for compliance reporting
internal/converter/builder/helpers.goSpecialized helper functions including EscapePipeForMarkdown() and TruncateString() for table content safety
internal/converter/formatters/formatters.goHelper functions for data transformation, escaping, and markdown link generation
internal/converter/markdown.goAdditional markdown generation utilities and table construction helpers
  • Fluent Interface Pattern: Design pattern where method chaining enables readable, declarative code
  • Builder Pattern: Creational design pattern for constructing complex objects step by step
  • Adapter Pattern: Structural pattern used in section writers to adapt string-based methods to io.Writer interface
  • Interface Segregation Principle (ISP): SOLID principle stating that clients should not depend on interfaces they don't use; implemented by splitting ReportBuilder into SectionBuilder, TableWriter, and ReportComposer
  • Type Assertion in Go: Runtime mechanism for checking if an interface value holds a specific concrete type
  • io.Writer Interface: Standard Go interface for streaming output to various destinations
  • Template-based vs Programmatic Generation: Trade-offs between flexibility and type safety in document generation
  • nao1215/markdown Library: Open-source Go library providing fluent markdown generation API
Markdown Builder Pattern | Dosu