GoBlog

module
v2.4.0-beta2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 11, 2026 License: GPL-3.0

README

GoBlog

Go Reference Go Report Card Test DockerHub

GoBlog is a flexible blog generation and serving system for creating static blog feeds from Markdown files. It provides a powerful parser for Markdown content with YAML frontmatter, as well as multiple deployment options including a CLI tool, Docker image, and embeddable Go package.

The project is designed for developers who want a simple, Go-based solution for blog generation with support for modern Markdown features like syntax highlighting, footnotes, and custom templates.

Installation

CLI tool

Install the goblog binary to $GOPATH/bin (ensure that directory is on your $PATH):

go install github.com/harrydayexe/GoBlog/v2/cmd/goblog@latest

To install from a local checkout instead, run from the repo root:

go install ./cmd/goblog
Library

Add GoBlog as a dependency in your Go module:

go get github.com/harrydayexe/GoBlog/v2

Quick Start

Using the Parser Package

The parser package reads Markdown files with YAML frontmatter and converts them to structured Post objects:

package main

import (
    "context"
    "fmt"
    "log"
    "os"

    "github.com/harrydayexe/GoBlog/v2/pkg/parser"
)

func main() {
    // Create a new parser (syntax highlighting enabled by default)
    p := parser.New()

    // Parse a single markdown file
    postsFS := os.DirFS("posts/")
    post, err := p.ParseFile(context.Background(), postsFS, "my-post.md")
    if err != nil {
        log.Fatal(err)
    }

    fmt.Printf("Title: %s\n", post.Title)
    fmt.Printf("Date: %s\n", post.FormattedDate())
    fmt.Printf("Tags: %v\n", post.Tags)
}
Parsing Multiple Posts
// Parse all markdown files in a directory
posts, err := p.ParseDirectory(context.Background(), postsFS)
if err != nil {
    log.Fatal(err)
}

// Sort by date and filter by tag
posts.SortByDate()
goPosts := posts.FilterByTag("go")

fmt.Printf("Found %d posts about Go\n", len(goPosts))

Documentation

Full API documentation is available at:

Markdown Post Format

Posts are written in Markdown with YAML frontmatter:

---
title: "My Blog Post Title"
date: 2024-01-15
description: "A brief description of the post"
tags: ["go", "blogging", "markdown"]
---

# Post Content

Your blog post content goes here with **full Markdown support**.

\`\`\`go
func main() {
    fmt.Println("Code highlighting included!")
}
\`\`\`

CLI Usage

# Generate static blog (alias: goblog g)
goblog generate posts/ output/

# Serve blog locally
goblog serve posts/ --port 8080
generate flags
Flag Short Default Description
--raw -r false Output raw HTML without template wrapping
--disable-tags -T false Disable tag tracking and tag page generation
--disable-reading-time false Disable reading time estimation on posts
--root-path -p / Blog root path for subdirectory deployment
--template-dir -t built-in Path to a custom template directory
serve flags
Flag Short Default Description
--port -P 8080 TCP port to listen on
--host -H all interfaces Host address to bind to
--disable-tags -T false Disable tag tracking and tag page generation
--disable-reading-time false Disable reading time estimation on posts
--root-path -p / Blog root path for subdirectory deployment
--template-dir -t built-in Path to a custom template directory

Advanced Features

Syntax Highlighting CSS

The parser renders code blocks using CSS classes (via chroma's html.WithClasses option) rather than inline styles. This keeps the generated HTML clean but means you must include a matching stylesheet in your templates.

Generate the stylesheet for any chroma style at startup and embed it in a <style> tag:

import (
    chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
    "github.com/alecthomas/chroma/v2/styles"
    "strings"
)

formatter := chromahtml.New(chromahtml.WithClasses(true))
style := styles.Get("monokai") // choose any chroma style name
var sb strings.Builder
formatter.WriteCSS(&sb, style)
chromaCSS := sb.String() // embed in a <style> tag in your template

There is currently no API to change the highlighter style from within GoBlog — pick whichever style you like when generating the stylesheet above and the CSS class names will match.

The CSS class names follow the Pygments short-name convention (.k for keyword, .s for string, .nf for function name, etc.). A full reference is in chroma's types.go.

Footnotes are disabled by default. Enable them with parser.WithFootnote():

p := parser.New(parser.WithFootnote())
Raw Output Mode

GoBlog supports raw HTML output mode for advanced use cases where you need direct access to the generated HTML without template wrappers. This is useful when:

  • Integrating GoBlog output into an existing site or CMS
  • Building custom templates in your own application
  • Embedding blog content in other HTML frameworks
  • Processing HTML content programmatically before display
Using Raw Output with the CLI
# Generate raw HTML files without templates
goblog generate posts/ output/ --raw

# Or use the short flag
goblog generate posts/ output/ -r

When raw output mode is enabled:

  • Individual post files (under posts/) contain only the parsed Markdown converted to HTML
  • No template wrapping is applied to the content
  • The tags/ directory is not created (tag pages are skipped)
  • index.html is still written by DirectoryWriter but its body is empty
Using Raw Output with the Go API
package main

import (
    "context"
    "os"

    "github.com/harrydayexe/GoBlog/v2/pkg/config"
    "github.com/harrydayexe/GoBlog/v2/pkg/generator"
    "github.com/harrydayexe/GoBlog/v2/pkg/outputter"
)

func main() {
    // Create generator with raw output enabled (no renderer needed in raw mode)
    fsys := os.DirFS("posts/")
    gen := generator.New(fsys, nil, config.WithRawOutput())

    // Generate the blog
    blog, err := gen.Generate(context.Background())
    if err != nil {
        panic(err)
    }

    // Access raw HTML bytes directly
    for slug, htmlContent := range blog.Posts {
        // Process or wrap htmlContent as needed
        // htmlContent contains only the parsed Markdown as HTML
    }

    // Write to disk with raw output mode
    writer := outputter.NewDirectoryWriter("output/", config.WithRawOutput())
    writer.HandleGeneratedBlog(context.Background(), blog)
}
What to Expect from Raw Output

When RawOutput is enabled, the GeneratedBlog structure contains:

  • Posts map: Keys are post slugs (derived from post titles or filenames), values are raw HTML byte slices containing only the Markdown content converted to HTML
  • Index field: Empty in raw mode — no index page is generated
  • Tags map: Empty — tag pages are not generated in raw output mode
  • TagsIndex field: Empty — tags index is not generated in raw output mode

The HTML content is clean, semantic HTML generated from your Markdown without any surrounding structure like <html>, <head>, or <body> tags. This gives you complete control over how to integrate the content into your site.

Disable Tags Mode

GoBlog can be configured to skip all tag-related output while still applying full templates to posts and the index page. This is useful when:

  • Your content is not taxonomy-driven and you don't need tag pages
  • You want a simpler site structure without a /tags/ section
  • You are using a custom navigation scheme in place of GoBlog's built-in tag pages
Using Disable Tags with the CLI
# Generate without tag pages
goblog generate posts/ output/ --disable-tags

# Or use the short flag
goblog generate posts/ output/ -T

# Also works with serve
goblog serve posts/ --disable-tags

When disable tags mode is enabled:

  • Individual post files are rendered with full templates; with the default templates, per-post tag pills are hidden
  • The tags/ directory is not created — no individual tag pages or tags index
  • index.html is rendered as normal; with the default templates, the "Tags" nav link in the header is hidden
  • The /tags and /tags/{tag} routes return 404 in serve mode
Using Disable Tags with the Go API
package main

import (
    "context"
    "os"

    "github.com/harrydayexe/GoBlog/v2/pkg/config"
    "github.com/harrydayexe/GoBlog/v2/pkg/generator"
    "github.com/harrydayexe/GoBlog/v2/pkg/outputter"
    "github.com/harrydayexe/GoBlog/v2/pkg/templates"
)

func main() {
    fsys := os.DirFS("posts/")
    renderer, _ := generator.NewTemplateRenderer(templates.Default)
    gen := generator.New(fsys, renderer, config.WithDisableTags())

    blog, err := gen.Generate(context.Background())
    if err != nil {
        panic(err)
    }

    // blog.Tags is an empty map; blog.TagsIndex is nil
    // blog.Posts and blog.Index contain fully-rendered HTML without tag UI

    writer := outputter.NewDirectoryWriter("output/", config.WithDisableTags())
    writer.HandleGeneratedBlog(context.Background(), blog)
}
What to Expect from Disable Tags Output

When DisableTags is enabled, the GeneratedBlog structure contains:

  • Posts map: Keys are post slugs, values are fully-templated HTML pages; with the default templates, tag pills are not rendered
  • Index field: Fully-templated index page; with the default templates, the "Tags" nav link is not rendered
  • Tags map: Empty — individual tag pages are not generated
  • TagsIndex field: Empty — the tags index page is not generated
Custom Templates and TagsEnabled

The BaseData struct passed to all templates includes a TagsEnabled bool field. Custom templates should use this field to conditionally render tag-related UI:

{{if .TagsEnabled}}
<a href="{{.BlogRoot}}tags">Tags</a>
{{end}}

The built-in templates already respect this field. If you provide custom templates that unconditionally render tag links, those links will still appear even when --disable-tags is set — update them to gate on {{.TagsEnabled}}.

Reading Time

By default, GoBlog computes an estimated reading time for each post and the default templates display it inline next to the post date (e.g. March 15, 2026 · 5 min read). Reading time is calculated by stripping HTML tags from the rendered content, counting whitespace-separated words, and dividing by 220 WPM with ceiling rounding and a 1-minute floor.

Using Disable Reading Time with the CLI
# Generate without reading time annotations
goblog generate posts/ output/ --disable-reading-time

# Also works with serve
goblog serve posts/ --disable-reading-time

When reading time is disabled:

  • Post.ReadingTimeMinutes is left at zero for all posts
  • The default templates suppress the · N min read annotation (they guard it with {{if .Post.ReadingTimeMinutes}})
  • No other output changes — posts, index, and tag pages are unaffected
Using Disable Reading Time with the Go API
gen := generator.New(fsys, renderer, config.WithDisableReadingTime())
Custom Templates and ReadingTimeMinutes

The Post struct includes a ReadingTimeMinutes int field (zero when disabled). Custom templates can render it conditionally:

{{if .Post.ReadingTimeMinutes}} · {{.Post.ReadingTimeMinutes}} min read{{end}}
Blog Root Configuration

When deploying your blog at a subdirectory rather than the root of your domain (e.g., example.com/blog/ instead of example.com/), you need to configure the blog root path. This ensures all generated links in templates use the correct base path.

Using Blog Root with the CLI
# Generate blog for deployment at /blog/ subdirectory
goblog generate posts/ output/ --root-path /blog/

# Or use the short flag
goblog generate posts/ output/ -p /blog/

# Serve blog locally with custom root path
goblog serve posts/ --root-path /blog/ --port 8080

When blog root is configured:

  • All internal links (navigation, post links, tag links) will use the specified root path
  • Links are generated as /blog/posts/my-post.html instead of /posts/my-post.html
  • Home links point to /blog/ instead of /
  • Tag links use /blog/tags/tag-name.html instead of /tags/tag-name.html
Using Blog Root with the Go API
package main

import (
    "context"
    "os"

    "github.com/harrydayexe/GoBlog/v2/pkg/config"
    "github.com/harrydayexe/GoBlog/v2/pkg/generator"
    "github.com/harrydayexe/GoBlog/v2/pkg/outputter"
)

func main() {
    // Create generator with blog root for subdirectory deployment
    fsys := os.DirFS("posts/")
    renderer, _ := generator.NewTemplateRenderer(templates.Default)
    gen := generator.New(fsys, renderer, config.WithBlogRoot("/blog/"))

    // Generate the blog
    blog, err := gen.Generate(context.Background())
    if err != nil {
        panic(err)
    }

    // Write to disk - all links will use /blog/ prefix
    writer := outputter.NewDirectoryWriter("output/")
    writer.HandleGeneratedBlog(context.Background(), blog)
}
Common Use Cases

Root Deployment (Default):

# Blog at example.com/
goblog generate posts/ output/
# Links: /posts/slug.html, /tags/tag.html, /

Subdirectory Deployment:

# Blog at example.com/blog/
goblog generate posts/ output/ --root-path /blog/
# Links: /blog/posts/slug.html, /blog/tags/tag.html, /blog/

Nested Subdirectory:

# Blog at example.com/docs/blog/
goblog generate posts/ output/ --root-path /docs/blog/
# Links: /docs/blog/posts/slug.html, /docs/blog/tags/tag.html, /docs/blog/
Custom Templates

GoBlog ships with built-in templates (templates.Default) but you can supply any fs.FS — e.g. os.DirFS("./mytheme") — to generator.NewTemplateRenderer for a fully custom theme.

Required directory layout

Your template root must contain:

mytheme/
  pages/
    post.tmpl          receives models.PostPageData
    index.tmpl         receives models.IndexPageData
    tag.tmpl           receives models.TagPageData
    tags-index.tmpl    receives models.TagsIndexPageData
  partials/
    head.tmpl          must {{define "head"}}
    header.tmpl        must {{define "header"}}
    footer.tmpl        must {{define "footer"}}
    post-card.tmpl     must {{define "post-card"}}
  layouts/             optional — loaded but not executed by default

Each page template is a complete HTML document. It pulls in partials with:

{{template "head" .}}
{{template "header" .}}
{{template "footer" .}}

The post-card partial is referenced inside index.tmpl and tag.tmpl when ranging over a list of posts.

Template data

Every page template receives a struct that embeds models.BaseData (SiteTitle, PageTitle, Description, Year, BlogRoot, Environment, TagsEnabled, Custom), plus page-specific fields:

Template Data struct Extra fields
pages/post.tmpl PostPageData .Post *Post (includes .Post.ReadingTimeMinutes int)
pages/index.tmpl IndexPageData .Posts PostList, .TotalPosts int
pages/tag.tmpl TagPageData .Tag string, .Posts []*Post, .PostCount int — not rendered when --disable-tags is set
pages/tags-index.tmpl TagsIndexPageData .Tags []TagInfo, .TotalTags int — not rendered when --disable-tags is set
Template helpers (FuncMap)
Name Signature Example output
formatDate func(t time.Time) string "January 2, 2006"
shortDate func(t time.Time) string "Jan 2, 2006"
year func() int 2025
Go API example
package main

import (
    "context"
    "os"

    "github.com/harrydayexe/GoBlog/v2/pkg/config"
    "github.com/harrydayexe/GoBlog/v2/pkg/generator"
    "github.com/harrydayexe/GoBlog/v2/pkg/outputter"
)

func main() {
    // Use a custom template directory
    renderer, err := generator.NewTemplateRenderer(os.DirFS("./mytheme"))
    if err != nil {
        panic(err)
    }

    fsys := os.DirFS("posts/")
    gen := generator.New(fsys, renderer, config.WithBlogRoot("/blog/"))

    blog, err := gen.Generate(context.Background())
    if err != nil {
        panic(err)
    }

    writer := outputter.NewDirectoryWriter("output/")
    writer.HandleGeneratedBlog(context.Background(), blog)
}

To use the built-in templates instead, replace os.DirFS("./mytheme") with templates.Default (from github.com/harrydayexe/GoBlog/v2/pkg/templates).

Go Package Reference

Package Summary
pkg/parser Parse Markdown + YAML frontmatter into Post objects
pkg/models Core data types: Post, PostList, page data structs
pkg/generator Convert a directory of posts into a GeneratedBlog in memory
pkg/outputter Write a GeneratedBlog to disk; implement Outputter for custom destinations
pkg/server HTTP server with atomic live-reload via Server.UpdatePosts
pkg/config Functional options: WithRawOutput, WithDisableTags, WithDisableReadingTime, WithSiteTitle, WithBlogRoot, WithEnvironment, WithPort, WithHost, WithMiddleware, WithFuncs, WithCustomData
pkg/templates Embedded default templates (templates.Default)
Site title
gen := generator.New(fsys, renderer, config.WithSiteTitle("My Blog"))
Runtime environment

GoBlog surfaces the environment to all templates as {{.Environment}}. Set it via the ENVIRONMENT env var (default "local", valid values: "local", "test", "production"), or pass it directly:

gen := generator.New(fsys, renderer, config.WithEnvironment("production"))

Use it in templates to gate environment-specific markup:

{{if eq .Environment "production"}}<script src="/analytics.js"></script>{{end}}
Custom template functions

Register your own Go functions to call from any template. Pass one or more config.WithFuncs options to generator.NewTemplateRenderer:

import (
    "html/template"
    "strings"

    "github.com/harrydayexe/GoBlog/v2/pkg/config"
    "github.com/harrydayexe/GoBlog/v2/pkg/generator"
    "github.com/harrydayexe/GoBlog/v2/pkg/templates"
)

renderer, err := generator.NewTemplateRenderer(
    templates.Default,
    config.WithFuncs(template.FuncMap{
        "upper": strings.ToUpper,
    }),
)

In a template:

<h1>{{upper .Post.Title}}</h1>

Multiple WithFuncs calls accumulate; later registrations overwrite earlier ones for the same key.

The built-in helpers (formatDate, shortDate, year) remain available unless intentionally replaced. Registering a function whose name matches a built-in replaces it and logs a warning — useful for a custom date format, but a footgun if done accidentally.

Security: html/template's contextual auto-escaping is bypassed for any function that returns template.HTML, template.JS, template.JSStr, template.URL, template.CSS, or template.HTMLAttr. Never use those return types with values derived from user-controlled input — doing so opts the value out of escaping and creates an XSS sink. Return plain string instead and let html/template escape at render time.

When using the embedded HTTP server, supply renderer options via ServerConfig.RendererOpts:

cfg := config.ServerConfig{
    RendererOpts: []config.RendererOption{
        config.WithFuncs(template.FuncMap{"upper": strings.ToUpper}),
    },
}
Custom template data

Inject arbitrary key-value data from your application into every rendered page. Supply a map[string]any via config.WithCustomData when constructing the generator:

gen := generator.New(fsys, renderer,
    config.WithCustomData(map[string]any{
        "author":      "Jane Smith",
        "analyticsID": "UA-12345",
    }),
)

The values are accessible in all templates as {{.Custom.key}}:

{{with .Custom}}
<meta name="author" content="{{.author}}">
<script>var _id = "{{.analyticsID}}";</script>
{{end}}

{{.Custom}} is nil when no WithCustomData option is supplied — templates should guard access with {{with .Custom}} or {{if .Custom}}.

Multiple WithCustomData calls merge their maps; later values overwrite earlier ones for duplicate keys.

Security: Store only plain, immutable values (strings, numbers, booleans) in the custom data map. Do not pre-wrap values in template.HTML, template.JS, or other sanitised wrapper types — those bypass html/template's contextual auto-escaping and become XSS sinks if the value originates from user-controlled input. Do not construct custom data from untrusted input at request time; this map is for static, developer-controlled values only.

Serving programmatically

pkg/server provides Server, which supports atomic handler hot-swapping — new posts can be loaded without restarting the process:

cfg := config.ServerConfig{
    Server: []config.BaseServerOption{
        config.WithPort(8080),
        config.WithMiddleware(logging.New(logger)),
    },
    Gen: []config.GeneratorOption{
        config.WithSiteTitle("My Blog"),
    },
}

srv, err := server.New(logger, os.DirFS("posts/"), cfg)
if err != nil {
    log.Fatal(err)
}

// Swap in new posts atomically while running:
srv.UpdatePosts(os.DirFS("posts/"), context.Background())

// Block until SIGINT/SIGTERM:
srv.Run(context.Background())
Custom output destination

Implement the outputter.Outputter interface to write generated content anywhere (database, S3, etc.):

type Outputter interface {
    HandleGeneratedBlog(context.Context, *generator.GeneratedBlog) error
}

Docker Usage

# Run blog server in container
docker run -v ./posts:/posts -p 8080:8080 goblog/goblog serve /posts

Contributing

Contributions are welcome! Please feel free to submit issues or pull requests.

License

GNU General Public License v3.0 - see LICENSE file for details.

Directories

Path Synopsis
cmd
goblog command
internal
errors
Package errors provides error handling utilities with colored terminal output for the CLI.
Package errors provides error handling utilities with colored terminal output for the CLI.
logger
Package logger provides CLI logging with colored output using slog.
Package logger provides CLI logging with colored output using slog.
utilities
Package utilities provides helper functions for CLI input validation and error handling.
Package utilities provides helper functions for CLI input validation and error handling.
pkg
config
Package config provides common configuration options used across the GoBlog generator and outputter packages.
Package config provides common configuration options used across the GoBlog generator and outputter packages.
generator
Package generator provides functionality for generating static HTML blog sites from markdown files.
Package generator provides functionality for generating static HTML blog sites from markdown files.
models
Package models provides data structures for representing blog posts and collections.
Package models provides data structures for representing blog posts and collections.
outputter
Package outputter provides interfaces and implementations for handling generated blog content from the generator package.
Package outputter provides interfaces and implementations for handling generated blog content from the generator package.
parser
Package parser reads markdown files with YAML frontmatter and converts them to Post objects for the GoBlog system.
Package parser reads markdown files with YAML frontmatter and converts them to Post objects for the GoBlog system.
server
Package server provides an HTTP server for serving generated blog content with support for live content updates via atomic handler hot-swapping.
Package server provides an HTTP server for serving generated blog content with support for live content updates via atomic handler hot-swapping.
templates
Package templates provides embedded default templates for the GoBlog system.
Package templates provides embedded default templates for the GoBlog system.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL