GoBlog

GoBlog is a blog generation and serving system for creating static blog feeds from Markdown files. It is available as a CLI tool, a Docker image, and an embeddable Go library.
CLI
Install the goblog binary:
go install github.com/harrydayexe/GoBlog/v2/cmd/goblog@latest
# Generate static files
goblog generate posts/ output/
# Serve locally
goblog serve posts/
Global flags
These flags apply to both generate and serve and may be passed before or after the subcommand name.
| Flag |
Short |
Default |
Description |
--template-dir |
-t |
built-in |
Path to a custom template directory |
--root-path |
-p |
/ |
Blog root path for subdirectory deployment |
--disable-tags |
-T |
false |
Disable tag tracking and tag page generation |
--disable-reading-time |
|
false |
Disable reading time estimation on posts |
--base-url |
|
(none) |
Scheme + host of the site (e.g. https://example.com); required to generate RSS/Atom feeds and canonical/Open Graph URLs. Must not include a path — use --root-path for subdirectory deployments |
--disable-feeds |
|
false |
Disable RSS and Atom feed generation |
--feed-limit |
|
10 |
Maximum number of posts to include in each feed (0 = unlimited) |
--assets-dir |
|
<posts>/images |
Directory of images, served at {root-path}images/ and copied to <output>/images/. Ignored if it does not exist |
--disable-sitemap |
|
false |
Disable sitemap.xml generation |
--disable-robots |
|
false |
Disable robots.txt generation |
--robots-file |
|
(none) |
Path to a custom robots.txt whose contents replace the default rules. The Sitemap: line is still appended when a sitemap is generated. Cannot be combined with --disable-robots |
--base-url also enables sitemap.xml and robots.txt. With generate, both are written to the top of the output directory; when --root-path is not /, move robots.txt to your domain root on deploy, since crawlers only read /robots.txt.
generate flags
| Flag |
Short |
Default |
Description |
--raw |
-r |
false |
Output raw HTML without template wrapping |
serve flags
When --base-url is set, the server also exposes the generated feeds at {root-path}rss.xml, {root-path}atom.xml, and per-tag feeds at {root-path}tags/{tag}.rss.xml / {root-path}tags/{tag}.atom.xml. The sitemap is served at {root-path}sitemap.xml, and robots.txt at /robots.txt — the origin root, where crawlers look for it, regardless of --root-path.
| Flag |
Short |
Default |
Description |
--port |
-P |
8080 |
TCP port to listen on |
--host |
-H |
all interfaces |
Host address to bind to |
--watch |
-w |
false |
Watch the posts directory and regenerate on changes |
--cache-control |
|
1h |
Max-age TTL for the Cache-Control header (0 disables) |
--health-checks |
|
false |
Expose /healthz/live, /healthz/ready, and /healthz/startup endpoints (no auth required); server binds before loading content so probes observe startup state |
Shell completion
goblog can generate shell completion scripts at runtime. After installing the
binary, source the appropriate script to enable tab-completion of subcommands and
flags.
Bash — add to ~/.bashrc:
source <(goblog completion bash)
Zsh — add to ~/.zshrc (requires compinit to be loaded):
autoload -Uz compinit && compinit
source <(goblog completion zsh)
Docker
The official image is harrydayexe/goblog. It runs goblog serve --health-checks /posts by default and exposes port 8080. Health-check endpoints are enabled in the Docker image. File watching is off by default; pass --watch to enable it.
Mount your Markdown posts directory to /posts:
docker run -v ./posts:/posts -p 8080:8080 harrydayexe/goblog
The image exposes three health-check endpoints that require no authentication:
| Endpoint |
Purpose |
Response |
GET /healthz/live |
Liveness probe |
200 ok (always) |
GET /healthz/ready |
Readiness probe |
200 ok once posts are loaded; 503 while starting or on error |
GET /healthz/startup |
Startup probe |
Same semantics as /healthz/ready |
To watch for post changes and reload automatically:
docker run -v ./posts:/posts -p 8080:8080 harrydayexe/goblog /posts --watch
Pass any serve flags after the image name — re-supply the posts path as the first argument:
docker run -v ./posts:/posts -p 9000:9000 harrydayexe/goblog /posts --port 9000
docker run -v ./posts:/posts -p 8080:8080 harrydayexe/goblog /posts --root-path /blog/
Images in ./posts/images are served at /images/ with no extra flags.
For custom templates, mount your template directory and use --template-dir:
docker run \
-v ./posts:/posts \
-v ./mytheme:/mytheme \
-p 8080:8080 \
harrydayexe/goblog /posts --template-dir /mytheme
Library
Add GoBlog as a dependency:
go get github.com/harrydayexe/GoBlog/v2
The main packages are:
| Package |
Summary |
pkg/parser |
Parse Markdown + YAML frontmatter into Post objects |
pkg/generator |
Convert a posts directory into a GeneratedBlog in memory |
pkg/outputter |
Write a GeneratedBlog to disk or a custom destination |
pkg/server |
Embeddable HTTP server with atomic live-reload |
pkg/config |
Functional options for generator, outputter, and server |
pkg/models |
Core data types: Post, PostList, template data structs |
pkg/templates |
Embedded default templates (templates.Default) |
A minimal generate-and-write example:
package main
import (
"context"
"os"
"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, err := generator.NewTemplateRenderer(templates.Default)
if err != nil {
panic(err)
}
gen := generator.New(fsys, renderer)
blog, err := gen.Generate(context.Background())
if err != nil {
panic(err)
}
writer := outputter.NewDirectoryWriter("output/")
writer.HandleGeneratedBlog(context.Background(), blog)
}
Logger injection
Every component accepts a structured log/slog logger via config.WithLogger. When not supplied, each component falls back to slog.Default() at construction time.
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
// Generator and outputter
gen := generator.New(fsys, renderer,
config.WithLogger(logger).AsGeneratorOption(),
)
writer := outputter.NewDirectoryWriter("output/",
config.WithLogger(logger).AsGeneratorOption(),
)
// Server
cfg := config.ServerConfig{
Server: []config.BaseServerOption{
config.WithPort(8080),
config.WithLogger(logger).AsServerOption(),
},
}
srv, err := server.New(nil, postsFS, cfg)
// Watcher
w, err := watcher.New("posts/", config.WithLogger(logger).AsWatcherOption())
// Parser
p := parser.New(parser.WithLogger(logger))
The default templates emit social and search metadata with no template work required:
- Open Graph —
og:title, og:description, og:site_name, and og:type (article on posts, website elsewhere). Post pages also emit article:published_time, article:modified_time (from lastEdited), article:author, and one article:tag per tag.
- Schema.org JSON-LD — a
BlogPosting object on post pages, carrying the publish and modified dates, author, keywords, reading time, and publisher; a WebSite object elsewhere.
- Canonical URLs —
og:url and <link rel="canonical">.
- X/Twitter —
twitter:card, which reads its content from the Open Graph tags above.
Canonical URLs need to know the site's domain, so set --base-url (or config.WithBaseURL). Without it, every URL-bearing tag is omitted rather than emitted empty; the rest of the metadata is unaffected.
Setting a base URL also populates GeneratedBlog.Sitemap and GeneratedBlog.RobotsTxt, which pkg/outputter writes as sitemap.xml / robots.txt and pkg/server serves. Opt out with config.WithDisableSitemap() / config.WithDisableRobotsTxt(), or replace the default robots rules with config.WithRobotsTxt(body).
Custom templates can read the same values from the page data: {{.CanonicalURL}}, {{.OGType}}, and {{with .Article}} for the post's publish date, author, and tags. og:image is not emitted — posts have no image field.
Full API documentation, including all config options and template data types, is at pkg.go.dev/github.com/harrydayexe/GoBlog/v2.
Heading anchor links
Headings get auto-generated ids (## Future Work → id="future-work"). Besides standard [text](#future-work) links, posts can link to a heading in the same post with wikilink syntax:
See [[#Future Work]] or [[#Future Work|what comes next]].
As with standard links, targets are not validated, so a link to a missing heading renders without error. Links to other posts ([[other-post#heading]]) are not supported and render as plain text.
Images
Put images in an images/ directory inside your posts directory and reference them from a post in either form:


![[pipeline.png|A diagram of the pipeline]]
All three render as <img src="{root-path}images/pipeline.png">. Subdirectories are preserved (images/screenshots/a.png). A bare ![[pipeline.png]] has no alt text, so prefer the |label form. Absolute URLs and paths starting with / are left as written. As with heading links, missing image files are not reported.
Use --assets-dir to keep images elsewhere; if the directory does not exist, image support is simply off. serve reads images straight from disk, so adding or replacing one needs no reload (the directory must exist when the server starts). generate copies the directory into <output>/images/.
Library users pass the directory with config.WithAssetsDir, ideally via os.Root so symlinks cannot escape it:
root, err := os.OpenRoot("posts/images")
if err != nil {
panic(err)
}
defer root.Close()
writer := outputter.NewDirectoryWriter("output/", config.WithAssetsDir(root.FS()).AsGeneratorOption())
cfg.Server = append(cfg.Server, config.WithAssetsDir(root.FS()).AsServerOption())
Contributing
Contributions are welcome. See CONTRIBUTING.md for how to set up the project, run tests, and submit pull requests.
License
GNU General Public License v3.0 — see LICENSE for details.