ssg

package module
v0.0.0-...-a8c1395 Latest Latest
Warning

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

Go to latest
Published: Apr 30, 2026 License: MIT Imports: 15 Imported by: 1

README

ssg

A document transformation pipeline and composable static site generator toolkit for Go.

ssg provides the building blocks for a content pipeline in three phases:

load inputs into memory
  ↓
enrich / expand / contract
  add or remove artifacts, derive metadata
  ↓
materialize
  each artifact runs its own pipeline → emit outputs

Why? And Alternatives

There are many static site generators!

Most focus on a "no programming required" model which limits extensibility.

The closest model is metalsmith.

Core concepts

Context — site-wide state available to every Plugin and pipeline stage:

type Context struct {
    Globals   map[string]any
    OutputDir string
    Logger    *log.Logger
}

Plugin — the single interface for all pipeline phases:

type Plugin func(ctx *Context, artifacts *[]Artifact) error

Load, filter, expand, and materialize are all Plugins operating on the same artifact set. No structural boundary between phases.

Artifact — one unit of work: metadata plus the pipeline that produces it:

type Artifact struct {
    Meta     ContentSourceConfig // map[string]any with typed accessors
    Pipeline Pipeline
}

Pipeline — a named sequence of stages. Construct with NewPipeline:

func NewPipeline(name string, stages ...Stage) Pipeline

Stage — a single named pipeline step. Each step receives both the current content value and the page metadata, and can transform either or both:

type Stage interface {
    Name() string
    Run(ctx *Context, cfg ContentSourceConfig, in any) (any, error)
}

Use Step[I, O] to create a Stage from a typed function:

func Step[I, O any](name string, fn func(*Context, ContentSourceConfig, I) (O, error)) Stage

The pipeline carries content and metadata together. A step can:

  • Transform content only — []byte → []byte, ignore cfg
  • Mutate metadata only — any → any pass-through, write to cfg
  • Read metadata to transform content — e.g. pick a MIME type from cfg.OutputFile()
  • Read and write metadata while transforming content — e.g. wrap rendered HTML in a layout template

MetaLoader — parses raw file bytes into frontmatter metadata and body:

type MetaLoader func(raw []byte) (map[string]any, []byte, error)

Returning a nil map signals skip. The return type is map[string]any so loader implementations have no dependency on this module.

Rule — pairs a doublestar glob pattern with a loader and a pipeline:

type Rule struct {
    Pattern  string
    Loader   MetaLoader // nil or ssg.Skip = skip without reading
    Pipeline Pipeline
}

Usage

ctx := &ssg.Context{
    Globals:   map[string]any{"Site": siteConfig},
    OutputDir: "public",
    Logger:    log.Default(),
}

rules := []ssg.Rule{
    {
        Pattern: "**/*.md",
        Loader:  metayaml.Loader,
        Pipeline: ssg.NewPipeline("post",
            ssg.SetOutputFile(ssg.CleanURLs(".md", ".html")), // metadata only
            ssg.SetTemplateName("post.html"),                  // metadata only
            markdown.New(),                                    // []byte → []byte
            ssg.Must(ssg.NewPageRender("layout", fns)),        // reads+writes cfg, []byte → []byte
            ssg.WriteOutput,                                   // reads cfg, terminal sink
        ),
    },
    {Pattern: "**/_*"}, // nil Loader: skip draft files
}

var artifacts []ssg.Artifact
for _, p := range []ssg.Plugin{
    ssg.FileWalker("content", rules), // Phase 1: load
    removeDrafts,                     // Phase 2: contract
    addTaxonomy,                      // Phase 2: expand
    ssg.Render,                       // Phase 3: materialize
} {
    if err := p(ctx, &artifacts); err != nil {
        log.Fatal(err)
    }
}
One-to-many outputs

Use FanOut inside a pipeline to produce multiple output files from one source. Each branch is a full Pipeline; all branches receive the same input:

Pipeline: ssg.NewPipeline("post",
    ssg.FanOut("outputs",
        ssg.NewPipeline("html", ssg.SetOutputFile(ssg.CleanURLs(".md", ".html")), markdown.New(), ssg.WriteOutput),
        ssg.NewPipeline("txt",  ssg.SetOutputFile(ssg.UglyURLs(".md", ".txt")),  plaintext.New(), ssg.WriteOutput),
    ),
),
Writing a pipeline step

Implement a typed function and wrap it with Step. The function receives both the current content value and the mutable metadata map:

// Content-transforming step (metadata ignored):
var UpperCase = ssg.Step("uppercase", func(_ *ssg.Context, _ ssg.ContentSourceConfig, in []byte) ([]byte, error) {
    return bytes.ToUpper(in), nil
})

// Metadata-only step (content passed through unchanged):
func SetCanonical(base string) ssg.Stage {
    return ssg.Step("set-canonical", func(_ *ssg.Context, cfg ssg.ContentSourceConfig, in any) (any, error) {
        cfg["Canonical"] = base + cfg.OutputFile()
        return in, nil
    })
}

// Step that reads metadata to transform content:
var AddTitle = ssg.Step("add-title", func(_ *ssg.Context, cfg ssg.ContentSourceConfig, in []byte) ([]byte, error) {
    title := cfg.Get("Title")
    return append([]byte("<h1>"+title+"</h1>\n"), in...), nil
})

Use ssg.Must(ssg.NewPageRender("layout", fns)) to inline constructors that return (Stage, error).

Filtering
ssg.FilterArtifacts(func(meta ssg.ContentSourceConfig) bool {
    draft, _ := meta["draft"].(bool)
    return !draft
})
Taxonomy pages
byTag := ssg.GroupByStrings(artifacts, "Tags")
for tag, tagArtifacts := range byTag {
    artifacts = append(artifacts, ssg.NewPage(
        "tags/"+slug(tag)+"/index.html", "tag-list/index.html",
        map[string]any{"Tag": tag, "Pages": metaSlice(tagArtifacts)},
        tagPipeline,
    ))
}
Built-in metadata steps
Step What it does
SetOutputFile(transform) Applies a PathTransformer to SourcePath, writes OutputFile to cfg
SetTemplateName(name) Writes TemplateName to cfg if not already set by frontmatter

Both pass content through unchanged (any → any).

Path transformers
Function Example
CleanURLs(".md", ".html") posts/foo.mdposts/foo/index.html
UglyURLs(".md", ".html") posts/foo.mdposts/foo.html
SlugNormalize(next) lowercases and hyphenates before applying next

Sub-modules

Each sub-module is a separate Go module and can be imported independently. Meta sub-modules have no dependency on github.com/client9/ssg.

Pipeline stages (render/)

Each package returns a ssg.Stage (or a constructor for one).

Module Import path Description
htmlclean github.com/client9/ssg/render/htmlclean Normalizes HTML fragments via golang.org/x/net/html
markdown github.com/client9/ssg/render/markdown Markdown → HTML via Goldmark with GFM and auto heading IDs
minify github.com/client9/ssg/render/minify Minifies HTML/CSS/JS/SVG; MIME type from cfg.OutputFile()
shortcode github.com/client9/ssg/render/shortcode Embedded $cmd[args]{body} macro engine

The shortcode syntax:

$cmd
$cmd[arg1 arg2]
$cmd[name=value key="val"]
$cmd{body}
$cmd[args]{body}
$$   →  literal $
Metadata loaders (meta/)

Each package exports a single var Loader of type func([]byte) (map[string]any, []byte, error).

Module Import path Description
json github.com/client9/ssg/meta/json JSON object frontmatter ({\n...\n}\n)
yaml github.com/client9/ssg/meta/yaml YAML frontmatter (---\n...\n---\n) via go.yaml.in/yaml/v4
toml github.com/client9/ssg/meta/toml TOML frontmatter (+++\n...\n+++\n) via github.com/BurntSushi/toml
email github.com/client9/ssg/meta/email Email-style Key: Value headers; email.NewLoader(transformers...) for type coercion

The root module also provides two built-in loaders:

  • ssg.Passthrough — returns raw bytes as body with empty metadata; use for assets
  • ssg.Skip — unconditionally skips the file; explicit alternative to a nil Rule.Loader
Template functions (tmpl/)
Module Import path Description
stdfuncs github.com/client9/ssg/tmpl/stdfuncs Stdlib-only template.FuncMap; covers strings, math, collections, path, time, encoding, and URL helpers
t := template.New("page").Funcs(stdfuncs.FuncMap())

// Combine with your own:
fns := stdfuncs.Merge(stdfuncs.FuncMap(), template.FuncMap{"myFunc": myFunc})

Sample

sample/ is a complete working site: JSON frontmatter, HTML content with text/template macros, page templates, tag taxonomy, and HTML pretty-printing.

cd sample && make run   # renders to sample/public/

Development

make test    # go test ./...
make lint    # go mod tidy, gofmt, golangci-lint
make env     # install golangci-lint, goimports

Sub-modules each have their own go.mod. Run go test ./... from their directory, or use the workspace: go work sync at the repo root.

Documentation

Index

Constants

This section is empty.

Variables

View Source
var WriteOutput = Step("write-output", writeOutput)

WriteOutput is a Stage that writes pipeline output to disk. The output directory is taken from ctx.OutputDir (default "public"); the file path within it comes from cfg.OutputFile(). Parent directories are created as needed.

Functions

func CopyAssets

func CopyAssets(srcDir, destDir string) error

CopyAssets copies all files from srcDir to destDir, preserving the directory structure. Directories and files whose names begin with '.' are skipped.

func GroupByString

func GroupByString(artifacts []Artifact, field string) map[string][]Artifact

GroupByString groups artifacts by the string value of a single-value field (e.g. "Category"). Artifacts where the field is absent, empty, or not a string are skipped.

byCategory := ssg.GroupByString(artifacts, "Category")
for cat, catArtifacts := range byCategory { ... }

func GroupByStrings

func GroupByStrings(artifacts []Artifact, field string) map[string][]Artifact

GroupByStrings groups artifacts by a multi-value string field (e.g. "Tags"). An artifact appears in the result once for each value it contains.

The field value may be []string, []any (as produced by YAML/JSON parsers), or a bare string (a single value written without a list). Empty strings are skipped.

byTag := ssg.GroupByStrings(artifacts, "Tags")
for tag, tagArtifacts := range byTag { ... }

func Render

func Render(ctx *Context, artifacts *[]Artifact) error

Render is a Plugin that materializes all artifacts by running each one's Pipeline. Globals from ctx are merged into each artifact's Meta before the pipeline runs; page frontmatter wins on key collision.

func RunPipeline

func RunPipeline[T any](ctx *Context, cfg ContentSourceConfig, p Pipeline, input any) (T, error)

RunPipeline executes a Pipeline with the given input, returning the final output or any error encountered. The output is type-asserted to T, and a descriptive error is returned if the assertion fails. To just check for execution errors without caring about the output type, use T = any.

func WriteSitemap

func WriteSitemap(outDir, baseURL string, artifacts []Artifact) error

WriteSitemap generates a sitemap.xml in outDir listing all artifacts. baseURL is prepended to each artifact's OutputFile to form the full URL (trailing slashes on baseURL are trimmed automatically). Artifacts with an empty OutputFile are skipped.

Types

type Artifact

type Artifact struct {
	Meta     ContentSourceConfig
	Pipeline Pipeline
}

Artifact is a single unit of work: the page metadata plus the pipeline that will materialize it into output. Separating data (Meta) from behavior (Pipeline) keeps ContentSourceConfig a clean data map.

func NewPage

func NewPage(outputFile, templateName string, data map[string]any, pipeline Pipeline) Artifact

NewPage creates an Artifact for a page not backed by a file — taxonomy indexes, tag listings, RSS feeds, or any other programmatically-generated output.

data is merged first; OutputFile and TemplateName are then set from the explicit arguments and always win. Content is initialised to an empty byte slice so the pipeline has something to read.

p := ssg.NewPage(
    "tags/go/index.html", "tag-list/index.html",
    map[string]any{"Tag": "go", "Pages": tagPages},
    pipeline,
)

type BranchResult

type BranchResult struct {
	Name  string
	Value any
	Err   error
}

---- Fan-out ---------------------------------------------------------------

FanOut runs multiple branch Pipelines from a single input. All branches run regardless of individual failures — no short-circuit.

Each branch receives a shallow clone of cfg so metadata mutations (e.g. SetOutputFile) in one branch do not affect the others. The content value (in) is shared across branches. Immutability cannot be enforced at this level because the type is erased, but a branch that needs its own copy can open with an explicit clone step:

var CloneBytes = ssg.Step("clone-bytes",
    func(_ *ssg.Context, _ ssg.ContentSourceConfig, in []byte) ([]byte, error) {
        return bytes.Clone(in), nil
    })

Two usage patterns:

  • Non-terminal: FanOut is followed by another Stage. On success it returns (FanOutResult, nil) and the next stage receives the FanOutResult and can inspect each branch's Name, Value, and Err freely.

  • Terminal: FanOut is the last stage. On success the pipeline returns cleanly. On failure it returns (nil, FanOutResult): the FanOutResult implements the error interface so the full result — every branch outcome — is preserved and recoverable with errors.As:

    var far FanOutResult if errors.As(err, &far) { for _, b := range far.Branches { fmt.Println(b.Name, b.Err) } }

BranchResult holds the name, final output value, and error for one branch.

type ContentSourceConfig

type ContentSourceConfig map[string]any

ContentSourceConfig holds metadata and content for a single page. It is a plain map so that frontmatter parsers, synthetic page builders, and templates can all read and write it without a fixed schema.

func (ContentSourceConfig) Clone

Clone returns a shallow copy of the map.

func (ContentSourceConfig) Get

func (csc ContentSourceConfig) Get(key string) string

func (ContentSourceConfig) InputFile

func (csc ContentSourceConfig) InputFile() string

func (ContentSourceConfig) OutputFile

func (csc ContentSourceConfig) OutputFile() string

func (ContentSourceConfig) SourcePath

func (csc ContentSourceConfig) SourcePath() string

SourcePath returns the path of the source file relative to ContentDir. Used by SetOutputFile to derive the output path via a PathTransformer.

func (ContentSourceConfig) TemplateName

func (csc ContentSourceConfig) TemplateName() string

type Context

type Context struct {
	Globals   map[string]any
	OutputDir string
	Logger    *log.Logger
}

Context holds site-wide state shared across all Plugins and Renderers. It is readable and writable by plugins — for example, a nav-building plugin can compute navigation and store it in Globals for templates to consume.

type FanOutResult

type FanOutResult struct {
	Branches []BranchResult
}

FanOutResult holds the outcome of every branch. It implements the error interface so it can be returned directly as an error when branches fail, making the full result set recoverable via errors.As even after the pipeline stops.

func (FanOutResult) Error

func (r FanOutResult) Error() string

Error implements the error interface. It is called only when at least one branch failed; the message is the join of all branch error strings.

func (FanOutResult) Unwrap

func (r FanOutResult) Unwrap() []error

Unwrap returns all branch errors, so errors.As and errors.Is traverse into individual branch failures.

type MetaLoader

type MetaLoader func(raw []byte) (map[string]any, []byte, error)

MetaLoader parses raw file bytes into frontmatter metadata and body content. Returning a nil map signals that the file should be skipped. If the file has no recognisable frontmatter, return an empty map and the full raw bytes as body.

The return type is map[string]any rather than ContentSourceConfig so that loader implementations have no dependency on the ssg module. LoadContent casts the map to ContentSourceConfig before further processing.

var ContentLoader MetaLoader = func(in []byte) (map[string]any, []byte, error) {
	jbytes, body, err := tojson.FromFrontMatter(in)
	if err != nil {
		return nil, nil, err
	}
	meta := make(map[string]any)
	err = json.Unmarshal(jbytes, &meta)
	if err != nil {
		return nil, nil, err
	}
	return meta, body, nil
}

ContentLoader is the default loader. It assumes a text document with "front matter" typically markdown, with YAML meta data between '---' at the begining.

var Passthrough MetaLoader = func(raw []byte) (map[string]any, []byte, error) {
	return map[string]any{}, raw, nil
}

Passthrough is a MetaLoader that returns the raw file bytes as body with empty metadata. Use it for assets (images, CSS, JS) that should be copied to the output directory unchanged.

var Skip MetaLoader = func(_ []byte) (map[string]any, []byte, error) {
	return nil, nil, nil
}

Skip is a MetaLoader that unconditionally skips the file. A nil Loader in a Rule has the same effect; Skip makes the intent explicit.

Rule{Pattern: "**/_*", Loader: ssg.Skip}

type PathTransformer

type PathTransformer func(relPath string) string

PathTransformer maps a relative input path to a relative output path. The input path is relative to ContentDir. Returning an empty string skips the file.

Use CleanURLs or UglyURLs for the common cases, and wrap with modifiers like SlugNormalize to compose behaviour:

ssg.SlugNormalize(ssg.CleanURLs(".md", ".html"))

func CleanURLs

func CleanURLs(inputExt, outputExt string) PathTransformer

CleanURLs returns a PathTransformer that produces directory-style output paths, making pages accessible without a file extension in the browser.

foo.md       → foo/index.html     (/foo or /foo/)
bar/baz.md   → bar/baz/index.html
index.md     → index.html         (root index, not index/index.html)

func SlugNormalize

func SlugNormalize(next PathTransformer) PathTransformer

SlugNormalize returns a PathTransformer that lowercases the filename and replaces spaces and underscores with hyphens before passing to next.

"Foo Bar.md"  → "foo-bar.md"  → (next applies)
"my_post.md"  → "my-post.md"  → (next applies)

func UglyURLs

func UglyURLs(inputExt, outputExt string) PathTransformer

UglyURLs returns a PathTransformer that maps input files directly to same-named output files, replacing the extension.

foo.md       → foo.html
bar/baz.md   → bar/baz.html

type Pipeline

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

Pipeline is a named sequence of stages executed in order.

func NewPipeline

func NewPipeline(name string, stages ...Stage) Pipeline

NewPipeline constructor

func (Pipeline) Run

func (p Pipeline) Run(ctx *Context, cfg ContentSourceConfig, in any) (any, error)

type Plugin

type Plugin func(ctx *Context, artifacts *[]Artifact) error

Plugin is the single interface for all pipeline stages: load, expand, contract, enrich, and materialize. Each Plugin receives the full artifact set and may add, remove, or modify entries.

type Plugin func(ctx *Context, artifacts *[]Artifact) error

func FileWalker

func FileWalker(contentDir string, rules []Rule) Plugin

FileWalker returns a Plugin that walks contentDir, matches each file against rules in order, and appends the resulting Artifacts to the slice.

For each matched file the Loader is called once. Each Output in the matched Rule produces one Artifact, sharing the same parsed metadata but carrying its own Pipeline — enabling one-to-many outputs from a single source file.

Files matching no rule, or whose rule has a nil Loader, are skipped. Directories prefixed with "." are skipped entirely.

func FilterArtifacts

func FilterArtifacts(fn func(ContentSourceConfig) bool) Plugin

FilterArtifacts returns a Plugin that retains only the artifacts for which fn returns true. Use it to remove draft pages, future-dated posts, etc.

Example — exclude draft pages:

ssg.FilterArtifacts(func(meta ssg.ContentSourceConfig) bool {
    draft, _ := meta["draft"].(bool)
    return !draft
})

type Rule

type Rule struct {
	Pattern  string
	Loader   MetaLoader
	Pipeline Pipeline
}

Rule pairs a doublestar glob pattern with a MetaLoader and a Pipeline. FileWalker tries rules in order; the first pattern that matches the file's relative path wins. Files that match no rule are skipped. A nil Loader skips the file without reading it.

Rule{
    Pattern:  "**/*.md",
    Loader:   metayaml.Loader,
    Pipeline: ssg.NewPipeline("post",
        ssg.SetOutputFile(ssg.CleanURLs(".md", ".html")),
        markdown.New(),
        ssg.Must(ssg.NewPageRender("layout", fns)),
        ssg.WriteOutput,
    ),
}
Rule{Pattern: "**/_*"} // nil Loader: skip draft files

type Stage

type Stage interface {
	Name() string
	Run(ctx *Context, cfg ContentSourceConfig, in any) (any, error)
}

Stage is a single named pipeline step with type-erased execution. Use Step to create a Stage from a typed function.

func FanOut

func FanOut(name string, branches ...Pipeline) Stage

FanOut returns a Stage that runs each branch Pipeline with the same input. All branches run regardless of individual failures. On success it returns (FanOutResult, nil); on failure it returns (nil, FanOutResult) so the full result set is always recoverable via errors.As.

func Must

func Must(s Stage, err error) Stage

Must wraps a (Stage, error) constructor result, panicking if err is non-nil. Use it to inline constructors that return errors in a pipeline slice literal.

ssg.Must(ssg.NewPageRender("layout", fns))

func NewPageRender

func NewPageRender(tdir string, fns template.FuncMap) (Stage, error)

NewPageRender loads HTML templates from tdir and returns a DynStage that wraps each page's rendered content in the appropriate layout template.

Template selection: each page's ContentSourceConfig must contain a "TemplateName" key (e.g. "blog/single.html"). The directory portion routes to the right template set; the filename selects the template within it.

Content injection: the stage reads the pipeline's current output, stores it as the string value of page["Content"], then executes the named template with the full ContentSourceConfig as its data. Templates access the body via {{.Content}} and other page metadata via {{.Title}}, {{.Date}}, etc.

Template inheritance: templates in a parent directory are available to all templates in child directories. A template in layout/blog/ can call {{template "base.html" .}} because base.html is parsed into the same set. See TemplateRouter for details.

Block override constraint: all templates in the same directory share one template set, so only one template per directory may use {{define "main"}} (or any other block name). If two sibling templates both define the same block, Go's template engine will error. The solution is to give each template that overrides a block its own subdirectory:

layout/
  baseof.html       ← defines {{block "main" .}}
  post/
    index.html      ← {{define "main"}} for post pages
  tag-list/
    index.html      ← {{define "main"}} for tag listing pages
  tag-index/
    index.html      ← {{define "main"}} for the tag index page

Each subdirectory gets an isolated template set that inherits baseof.html from the parent but does not share its set with siblings.

fns is an optional map of additional template functions made available to all templates. Pass nil for no extra functions.

func NewStage

func NewStage(name string, fn func(ctx *Context, cfg ContentSourceConfig, in any) (any, error)) Stage

NewStage constructs a Stage from a name and a function.

func SetOutputFile

func SetOutputFile(transform PathTransformer) Stage

SetOutputFile returns a DynStage that applies transform to the artifact's SourcePath and stores the result as OutputFile in cfg. Content passes through unchanged, making this a metadata-only pipeline step. If transform returns "" the OutputFile is left unchanged.

func SetTemplateName

func SetTemplateName(name string) Stage

SetTemplateName returns a DynStage that sets TemplateName in cfg to name, but only if frontmatter hasn't already set it. Content passes through unchanged, making this a metadata-only pipeline step.

func Step

func Step[I, O any](name string, fn func(*Context, ContentSourceConfig, I) (O, error)) Stage

Step wraps a typed function into a Stage. The any type is confined here; stage functions use concrete types and are self-documenting.

Pass-through stages (metadata-only, content unchanged) use [any, any]:

Step("set-output-file", func(ctx *Context, cfg ContentSourceConfig, in any) (any, error) { ... })

Terminal sinks use struct{} as the output type:

Step("write-output", func(ctx *Context, cfg ContentSourceConfig, in []byte) (struct{}, error) { ... })

type TemplateRouter

type TemplateRouter map[string]*template.Template

TemplateRouter maps layout directory paths (relative to the layout root) to their compiled template sets. Keys are directory paths such as ".", "blog", or "blog/posts".

Each template set contains all templates parsed from the root down to that directory, so child sets inherit parent templates.

func (TemplateRouter) ExecuteTemplate

func (t TemplateRouter) ExecuteTemplate(wr io.Writer, name string, data any) error

ExecuteTemplate routes a template name to the correct set and executes it.

The name is expected to be a slash-separated path where the final component is the template filename and everything before it is the directory key. For example:

"base.html"           → directory ".",  template "base.html"
"blog/single.html"    → directory "blog", template "single.html"
"blog/posts/page.html"→ directory "blog/posts", template "page.html"

Directories

Path Synopsis
meta
email module
json module
jsonyaml module
render
htmlclean module

Jump to

Keyboard shortcuts

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