gotic

package module
v0.0.0-...-0d77be2 Latest Latest
Warning

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

Go to latest
Published: Jul 3, 2026 License: MIT Imports: 28 Imported by: 0

README

gotic

Static-binary site generator that outputs a self-contained webserver executable.

Your content (templates, static assets, and Markdown posts) is embedded into a single Go binary. Run it anywhere — no runtime, no external files.

A complete, runnable site lives in example/ — cd example && go run . and open http://localhost:4321.

Fair warning

I'm building this for myself, so there's no guarantee of stability.

Getting started

Create project directory
$ mkdir mysite
$ cd mysite
$ mkdir static templates posts
// mysite.go
package main

import (
        "embed"

        "codeberg.org/filipsalo/gotic"
)

//go:embed static templates posts
var content embed.FS

func main() {
        gotic.Main(content)
}
Run server in dev mode with hot reload
$ go run mysite.go

When started with go run, gotic overlays the on-disk templates/, posts/, and static/ directories over the embedded content, so edits show up on the next refresh without rebuilding.

Build server binary
$ go build -o mysite mysite.go
$ ./mysite

On startup the server prints a clickable link, e.g.:

Listening on http://localhost:4321
Choose a port

The binary accepts a -port flag (default 4321):

$ ./mysite -port 8080

Posts

Posts are Markdown files in posts/, each beginning with a front-matter block between --- delimiters:

---
Title: Hello, world
Date: 2026-06-09
Tags: intro meta
Slug: hello-world
---

The **body** is Markdown and is rendered with the `markdown` template func.
Front-matter fields
Field Meaning
Title Page title.
Date 2006-01-02 or 2006-01-02 15:04:05. Required for a post to list (a Draft may omit it). A future date schedules the post: hidden from listings (like Unlisted) but served by URL until its date arrives.
Slug URL slug; combined with Date to derive /2006/01/02/slug.
Path Explicit URL path, overriding the date/slug default.
Tags Space-separated list.
Draft true for a work in progress: listed and served only when running locally (under go run), and hidden — 404 on direct access — in production. A draft needs no Date; without one it's previewed at /drafts/<slug> (the slug falls back to the file name).
Unlisted true to keep a finished post out of listings while still serving it on direct access.
Aliases Comma-separated old URLs that 301-redirect here (e.g. after a slug change). Each is a root-relative path (/2006/05/24/old) or a bare slug (old, resolved against the page's own URL scheme).
Styles Comma-separated CSS files injected into <head>. A bare name (app.css) is a colocated asset under the page's own URL; a value starting with / or containing :// is used verbatim (a shared /static/... asset, or an external URL).
Scripts Like Styles, but emitted as deferred <script> tags.

Any other field is captured in .page.Meta (keyed by its lower-cased name), so you can add opt-in flags without code changes, e.g. a Notes: ... field is available as {{ .page.Meta.notes }}.

Pages

Pages are standalone Markdown files in pages/ (add pages to your //go:embed line to include them). Unlike posts they aren't dated and never appear in listings; each is served at /<slug> — the file or folder name, or an explicit Path/Slug — and rendered with page.html by default. They take the same front matter as posts.

Self-contained pages with colocated assets

A page can be a folder with an index.md instead of a single file. Its sibling files are then served as the page's own assets, under its URL:

pages/
  highlighter/
    index.md          → /highlighter
    playground.css    → /highlighter/playground.css
    playground.js     → /highlighter/playground.js

The page stays at /highlighter with no trailing slash; /highlighter/, the index.md source itself, and any ../ traversal are not served. A post can be a folder too (posts/2026-06-12-thing/index.md), with its assets under the post's dated URL.

Pair this with the Styles/Scripts front-matter fields to pull the assets into <head> without writing a bespoke template — a bare name resolves to the colocated file:

---
Title: Highlighter playground
Styles: playground.css
Scripts: playground.js
---

For default.html to honour them, emit them somewhere in <head>:

{{ range .page.Styles }}<link rel="stylesheet" href="{{ . }}">{{ end }}
{{ range .page.Scripts }}<script src="{{ . }}" defer></script>{{ end }}

Templates

Templates live in templates/. gotic ships default default.html (the page shell) and error.html; your site provides the page-specific templates it references — typically index.html (home), entry.html (a single post), page.html (a standalone page), and archive.html (the post index).

Rendering context

Each page is rendered with:

  • .page — the current Page (.page.Title, .page.Body, .page.Date, .page.Tags, .page.Meta, …).
  • .recent — the 15 most recent posts, newest first (for the home page).
  • .posts — all posts, newest first (for the archive).
Synthetic pages
  • / renders index.html.
  • /archive renders archive.html — registered only when the site has at least one post, so a post-less single-page site doesn't get a broken archive route.
Template functions
Func Description
markdown Render a Markdown string to HTML.
dateformat dateformat "2006-01-02" .page.Date — Go time formatting.
readingtime Estimated reading time in minutes (~200 wpm, rounded up).
safehtml Mark a string as safe, pre-escaped HTML.

Add your own with gotic.NewSite(content).WithTmplFuncs(funcs).

Serving multiple sites

Serve routes requests by Host header, so several independent sites can share one binary and port. gotic.Main(content) is shorthand for a single mount.

func main() {
        gotic.Serve(
                gotic.Mount{Domain: "infix.se", FS: infixContent, Dir: "infix.se"},
                gotic.Mount{Domain: "example.com", FS: exampleContent, Dir: "example.com"},
        )
}

Each Mount binds a domain to a content root. Hosts are normalized (port, trailing dot, and a leading www. are stripped), and every domain also answers on <label>.localhost for local development (so infix.se is reachable at http://infix.localhost:4321). The first mount is the fallback for unmatched hosts. Use Aliases to answer on additional hostnames.

Feeds

Set a Feed config on a mount to serve an Atom 1.0 feed at /feed.atom and a JSON Feed 1.1 at /feed.json. Both carry the full HTML of the most recent posts (drafts, unlisted, and future-dated posts are excluded, like every listing). A BaseURL is required — feeds need absolute URLs — and root-relative links in post bodies are rewritten to absolute ones so they resolve in readers.

func main() {
        gotic.Serve(gotic.Mount{
                FS:  content,
                Dir: ".",
                Feed: gotic.FeedConfig{
                        BaseURL:  "https://filipsalo.se",
                        Title:    "filipsalo.se",
                        Subtitle: "Filip Salo — software engineer",
                        Author:   "Filip Salo",
                },
        })
}

For multi-site Serve, BaseURL defaults to https://<Domain> when unset. Add autodiscovery links to your default.html <head> so readers find the feeds:

<link rel="alternate" type="application/atom+xml" href="/feed.atom" title="Atom feed">
<link rel="alternate" type="application/feed+json" href="/feed.json" title="JSON feed">

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Main

func Main(content fs.FS)

Main serves a single embedded site (templates/, posts/, static/) on the -port flag (default 4321), hot-reloading from the working tree in development. It is shorthand for Serve with one mount.

func Serve

func Serve(mounts ...Mount)

Serve builds a host-routed server over the given mounts and listens on the -port flag (default 4321). The first mount is the fallback for unmatched hosts; each domain also answers on "<label>.localhost". In development (run via `go run`), each mount hot-reloads templates/posts/static from its Dir.

Types

type FeedConfig

type FeedConfig struct {
	BaseURL  string // absolute site root, e.g. "https://filipsalo.se"
	Title    string // feed title; defaults to the BaseURL host
	Subtitle string // optional one-line description
	Author   string // author display name, e.g. "Filip Salo"
}

FeedConfig configures the Atom and JSON feeds. Feeds are only served when BaseURL is set, since a feed needs absolute URLs.

type Mount

type Mount struct {
	Domain  string     // canonical hostname, e.g. "infix.se"
	FS      fs.FS      // embedded content: templates/, static/, optional posts/
	Dir     string     // optional on-disk dir for dev hot-reload (e.g. "." or "filipsalo.se")
	Aliases []string   // optional extra hostnames to answer on
	Feed    FeedConfig // optional Atom + JSON feeds; defaults BaseURL to "https://<Domain>"
	// OGImage, if set, renders a social-card PNG for a page; the site then
	// serves it (cached) at "<page path>/og.png". The renderer owns the design.
	OGImage func(*Page) ([]byte, error)
}

Mount binds a hostname to a content root for Serve.

type Page

type Page struct {
	Path  string
	Slug  string
	Title string
	Date  time.Time
	// IsDraft marks a work in progress. Drafts are listed and served only when
	// drafts are enabled (in development); in production they're absent from
	// listings and 404 on direct access. A draft needs no date — without one it
	// gets a preview URL under /drafts/<slug>. Set with `Draft: true`.
	IsDraft bool
	// IsUnlisted keeps a finished, public post out of listings (the archive
	// and the home page's recent posts) while still serving it on direct
	// access. Set with `Unlisted: true` in the front matter.
	IsUnlisted bool
	IsPage     bool // a standalone page (from pages/), not a dated post
	Template   string
	Body       string
	Tags       []string
	Lang       string // BCP 47 language tag, e.g. "en" or "sv"
	Summary    string // a short (one or two sentence) description
	// Aliases are old URLs that should permanently redirect to this page,
	// e.g. after a slug change. Each is either a root-relative path
	// ("/2006/05/24/old-slug") or a bare slug ("old-slug"), the latter
	// resolved against the page's own URL scheme. Set with a comma-separated
	// `Aliases:` front-matter field.
	Aliases []string
	// Styles and Scripts are page-specific assets injected into <head>. A bare
	// name (app.css) resolves to the page's own URL — i.e. a file colocated in
	// its folder, served at "<page path>/app.css"; a value starting with "/" or
	// containing "://" is used verbatim (a shared "/static/..." asset or an
	// external URL). Set with comma-separated `Styles:` / `Scripts:` fields.
	Styles  []string
	Scripts []string
	// AssetDir is the content-FS directory whose sibling files are served as
	// this page's colocated assets, set when the page is a folder with an
	// index.md (otherwise ""). Its files are reachable at "<page path>/<file>".
	AssetDir string
	// Meta holds any front-matter fields that aren't recognized above,
	// keyed by their lower-cased name (e.g. {{ .page.Meta.notes }}).
	Meta map[string]string
}

Page represents a known web page

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server dispatches requests to per-host Sites based on the request Host header, so several independent sites can share one binary and port.

func NewServer

func NewServer() *Server

NewServer returns an empty multi-site server.

func (*Server) Add

func (srv *Server) Add(domain string, site *Site, aliases ...string) *Server

Add registers site under domain, an auto-derived "<label>.localhost" alias (so "infix.se" also answers as "infix.localhost" in dev), and any extra aliases. The first site added becomes the fallback for unmatched hosts.

func (*Server) ServeHTTP

func (srv *Server) ServeHTTP(w http.ResponseWriter, r *http.Request)

type Site

type Site struct {
	// contains filtered or unexported fields
}

Site represents a website

func NewSite

func NewSite(content fs.FS) *Site

NewSite creates a new site

func (*Site) Handler

func (s *Site) Handler() http.Handler

Handler returns the site's http.Handler, building (and caching) it on first use so a multi-site server can dispatch without rebuilding the mux on every request.

func (*Site) ListenAndServe

func (s *Site) ListenAndServe(addr string)

ListenAndServe starts the web server

func (*Site) WithDrafts

func (s *Site) WithDrafts(show bool) *Site

WithDrafts controls whether draft posts (those with `Draft: true` in their front matter) are listed and served. It's off by default; Serve and Main turn it on in development, so drafts are visible while running locally but never reachable in production.

func (*Site) WithFeed

func (s *Site) WithFeed(cfg FeedConfig) *Site

WithFeed configures the Atom and JSON feeds. With no BaseURL set, no feeds are served.

func (*Site) WithOGImage

func (s *Site) WithOGImage(fn func(*Page) ([]byte, error)) *Site

WithOGImage registers a function that renders a social-card PNG for a page. When set, the site serves "<page path>/og.png" by calling it and caching the bytes. The renderer owns the card's look and fonts; gotic only handles the route, the page lookup, and the cache — so card design stays in the site.

func (*Site) WithTmplFuncs

func (s *Site) WithTmplFuncs(funcs map[string]interface{}) *Site

WithTmplFuncs adds template functions

Directories

Path Synopsis
Command example is a minimal gotic site demonstrating the home page, a Markdown post, and the archive.
Command example is a minimal gotic site demonstrating the home page, a Markdown post, and the archive.

Jump to

Keyboard shortcuts

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