diff --git a/cmd/gmnhg/main.go b/cmd/gmnhg/main.go
index 67fb969..d06e8e7 100644
--- a/cmd/gmnhg/main.go
+++ b/cmd/gmnhg/main.go
@@ -1,8 +1,290 @@
-// gmnhg converts Hugo posts to gemini content.
+// This file is part of gmnhg.
+
+// gmnhg is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+// gmnhg is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with gmnhg. If not, see .
+
+// gmnhg converts Hugo content files to a Gemini site. This program is
+// to be started in the top level directory of a Hugo site (the one
+// containing config.toml).
//
-// TODO: it is yet to actually do that.
+// gmngh will read layout template files (with .gotmpl extension) and
+// then apply them to content files ending with .md by the following
+// algorithm (file names are relative to layouts/gmnhg):
+//
+// 1. If the .md file specifies its own layout, the relevant layout file
+// is applied. If not, the default template is applied (single). If the
+// layout file does not exist, the file is skipped. Draft posts are not
+// rendered. _index.md files are also skipped.
+//
+// 2. For every top-level content directory an index.gmi is generated,
+// the corresponding template is taken from top/{directory_name}.gotmpl.
+// If there's no matching template, the index won't be rendered.
+//
+// 3. The very top index.gmi is generated from index.gotmpl.
+//
+// The program will then copy static files from static/ directory to the
+// output dir.
+//
+// Templates are passed the following data:
+//
+// 1. Single pages are given .Post, which contains the entire post
+// rendered, .Metadata, which contains the metadata crawled from it (see
+// HugoMetadata), and .Link, which contains the filename relative to
+// content dir (with .md replaced with .gmi).
+//
+// 2. Directory index pages are passed .Posts, which is a slice over
+// post metadata crawled (see HugoMetadata), and .Dirname, which is
+// directory name relative to content dir.
+//
+// 3. The top-level index.gmi is passed with the .PostData map whose
+// keys are top-level content directories names and values are slices
+// over the same post props as specified in 1.
+//
+// This program provides some extra template functions, documented in
+// templates.go.
package main
-func main() {
- println("in development")
+import (
+ "bytes"
+ "flag"
+ "io"
+ "io/ioutil"
+ "os"
+ "path"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "text/template"
+
+ gemini "git.tdem.in/tdemin/gmnhg"
+)
+
+const defaultTemplate = "single"
+
+const (
+ contentBase = "content/"
+ templateBase = "layouts/gmnhg/"
+ staticBase = "static/"
+ outputBase = "output/"
+)
+
+var (
+ tmplNameRegex = regexp.MustCompile(templateBase + `(\w+)\.gotmpl`)
+ contentNameRegex = regexp.MustCompile(contentBase + `([\w-_ ]+)\.md`)
+ topLevelPostRegex = regexp.MustCompile(contentBase + `([\w-_ ]+)/([\w-_ ]+)\.md`)
+)
+
+// TODO: more meaningful errors
+
+type post struct {
+ Post []byte
+ Metadata gemini.HugoMetadata
+ Link string
+}
+
+func copyFile(dst, src string) error {
+ input, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer input.Close()
+ if p := path.Dir(dst); p != "" {
+ if err := os.MkdirAll(p, 0755); err != nil {
+ return err
+ }
+ }
+ output, err := os.Create(dst)
+ if err != nil {
+ return err
+ }
+ defer output.Close()
+ if _, err := io.Copy(output, input); err != nil {
+ return err
+ }
+ return nil
+}
+
+func writeFile(dst string, contents []byte) error {
+ if p := path.Dir(dst); p != "" {
+ if err := os.MkdirAll(p, 0755); err != nil {
+ return err
+ }
+ }
+ output, err := os.Create(dst)
+ if err != nil {
+ return err
+ }
+ defer output.Close()
+ if _, err := output.Write(contents); err != nil {
+ return err
+ }
+ return nil
+}
+
+func main() {
+ var outputDir, workingDir string
+ flag.StringVar(&outputDir, "output", outputBase, "output directory (will be created if missing)")
+ flag.StringVar(&workingDir, "working", "", "working directory (defaults to current directory)")
+ flag.Parse()
+
+ if workingDir != "" {
+ if err := os.Chdir(workingDir); err != nil {
+ panic(err)
+ }
+ }
+
+ if fileInfo, err := os.Stat("config.toml"); os.IsNotExist(err) || fileInfo.IsDir() {
+ panic("config.toml either doesn't exist or is a directory; not in a Hugo site dir?")
+ }
+
+ // build templates
+ templates := make(map[string]*template.Template)
+ if _, err := os.Stat(templateBase); !os.IsNotExist(err) {
+ if err := filepath.Walk(templateBase, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if info.IsDir() {
+ return nil
+ }
+ name := tmplNameRegex.FindStringSubmatch(path)
+ if name == nil || len(name) != 2 {
+ return nil
+ }
+ tmplName := name[1]
+ contents, err := ioutil.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ tmpl, err := template.New(tmplName).Funcs(funcMap).Parse(string(contents))
+ if err != nil {
+ return err
+ }
+ templates[tmplName] = tmpl
+ return nil
+ }); err != nil {
+ panic(err)
+ }
+ }
+
+ // render posts to Gemtext and collect top level posts data
+ posts := make(map[string]*post, 0)
+ topLevelPosts := make(map[string][]*post)
+ if err := filepath.Walk(contentBase, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if n := info.Name(); info.IsDir() || !strings.HasSuffix(n, ".md") || n == "_index.md" {
+ return nil
+ }
+ fileContent, err := ioutil.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ gemText, metadata, err := gemini.RenderMarkdown(fileContent, gemini.WithoutMetadata)
+ if err != nil {
+ return err
+ }
+ // skip drafts from rendering
+ if metadata.PostIsDraft {
+ return nil
+ }
+ key := strings.TrimPrefix(strings.TrimSuffix(path, ".md"), contentBase) + ".gmi"
+ p := post{
+ Post: gemText,
+ Link: key,
+ Metadata: metadata,
+ }
+ posts[key] = &p
+ if matches := topLevelPostRegex.FindStringSubmatch(path); matches != nil {
+ topLevelPosts[matches[1]] = append(topLevelPosts[matches[1]], &p)
+ }
+ return nil
+ }); err != nil {
+ panic(err)
+ }
+
+ // clean up output dir beforehand
+ if _, err := os.Stat(outputDir); os.IsNotExist(err) {
+ if err := os.MkdirAll(outputDir, 0755); err != nil {
+ panic(err)
+ }
+ } else {
+ dir, err := ioutil.ReadDir(outputDir)
+ if err != nil {
+ panic(err)
+ }
+ for _, d := range dir {
+ os.RemoveAll(path.Join(outputDir, d.Name()))
+ }
+ }
+
+ // render posts to files
+ for fileName, post := range posts {
+ var tmpl = defaultSingleTemplate
+ if pl := post.Metadata.PostLayout; pl != "" {
+ t, ok := templates[pl]
+ if !ok {
+ // no point trying to render pages with no layout
+ continue
+ }
+ tmpl = t
+ }
+ buf := bytes.Buffer{}
+ if err := tmpl.Execute(&buf, &post); err != nil {
+ panic(err)
+ }
+ if err := writeFile(path.Join(outputDir, fileName), buf.Bytes()); err != nil {
+ panic(err)
+ }
+ }
+ // render indexes for top-level dirs
+ for dirname, posts := range topLevelPosts {
+ tmpl, hasTmpl := templates["top/"+dirname]
+ if !hasTmpl {
+ continue
+ }
+ buf := bytes.Buffer{}
+ if err := tmpl.Execute(&buf, map[string]interface{}{
+ "Posts": posts,
+ "Dirname": dirname,
+ }); err != nil {
+ panic(err)
+ }
+ if err := writeFile(path.Join(outputDir, dirname, "index.gmi"), buf.Bytes()); err != nil {
+ panic(err)
+ }
+ }
+ // render index page
+ var indexTmpl = defaultIndexTemplate
+ if t, hasIndexTmpl := templates["index"]; hasIndexTmpl {
+ indexTmpl = t
+ }
+ buf := bytes.Buffer{}
+ if err := indexTmpl.Execute(&buf, map[string]interface{}{"PostData": topLevelPosts}); err != nil {
+ panic(err)
+ }
+ if err := writeFile(path.Join(outputDir, "index.gmi"), buf.Bytes()); err != nil {
+ panic(err)
+ }
+
+ // copy static files to output dir unmodified
+ if err := filepath.Walk(staticBase, func(p string, info os.FileInfo, err error) error {
+ if info.IsDir() {
+ return nil
+ }
+ return copyFile(path.Join(outputDir, strings.TrimPrefix(p, staticBase)), p)
+ }); err != nil {
+ panic(err)
+ }
}
diff --git a/cmd/gmnhg/templates.go b/cmd/gmnhg/templates.go
new file mode 100644
index 0000000..105b4b5
--- /dev/null
+++ b/cmd/gmnhg/templates.go
@@ -0,0 +1,65 @@
+// This file is part of gmnhg.
+
+// gmnhg is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+// gmnhg is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with gmnhg. If not, see .
+
+package main
+
+import (
+ "sort"
+ "text/template"
+)
+
+type postsSort []*post
+
+func (p postsSort) Len() int {
+ return len(p)
+}
+
+func (p postsSort) Less(i, j int) bool {
+ return p[i].Metadata.PostDate.After(p[j].Metadata.PostDate)
+}
+
+func (p postsSort) Swap(i, j int) {
+ t := p[i]
+ p[i] = p[j]
+ p[j] = t
+}
+
+func mustParseTmpl(name, value string) *template.Template {
+ return template.Must(template.New(name).Funcs(funcMap).Parse(value))
+}
+
+var funcMap template.FuncMap = template.FuncMap{
+ // sorts posts by date, newest posts go first
+ "sortPosts": func(posts []*post) []*post {
+ ps := make(postsSort, len(posts))
+ copy(ps, posts)
+ sort.Sort(ps)
+ return ps
+ },
+}
+
+var defaultSingleTemplate = mustParseTmpl("single", `# {{ .Metadata.PostTitle }}
+
+{{ .Metadata.PostDate.Format "2006-01-02 15:04" }}
+
+{{ printf "%s" .Post }}`)
+
+var defaultIndexTemplate = mustParseTmpl("index", `# Site index
+
+{{ range $dir, $posts := .PostData }}Index of {{ $dir }}:
+
+{{ range $p := $posts | sortPosts }}=> {{ $p.Link }} {{ $p.Metadata.PostDate.Format "2006-01-02 15:04" }} - {{ $p.Metadata.PostTitle }}
+{{ end }}{{ end }}
+`)
diff --git a/cmd/md2gmn/main.go b/cmd/md2gmn/main.go
index 8662af5..f5b26b3 100644
--- a/cmd/md2gmn/main.go
+++ b/cmd/md2gmn/main.go
@@ -47,7 +47,7 @@ func main() {
panic(err)
}
- geminiContent, _, err := gemini.RenderMarkdown(text, true)
+ geminiContent, _, err := gemini.RenderMarkdown(text, gemini.WithMetadata)
if err != nil {
panic(err)
}
diff --git a/render.go b/render.go
index 4de9c80..bd07869 100644
--- a/render.go
+++ b/render.go
@@ -54,6 +54,17 @@ var yamlDelimiter = []byte("---\n")
// supposed to be rendered.
var ErrPostIsDraft = errors.New("post is draft")
+// MetadataSetting defines whether or not metadata is included in the
+// rendered text.
+type MetadataSetting int
+
+// Metadata settings control the inclusion of metadata in the rendered
+// text.
+const (
+ WithMetadata MetadataSetting = iota
+ WithoutMetadata
+)
+
// RenderMarkdown converts Markdown text to text/gemini using
// gomarkdown, appending Hugo YAML front matter data if any is present
// to the post header.
@@ -63,7 +74,7 @@ var ErrPostIsDraft = errors.New("post is draft")
//
// Draft posts are still rendered, but with an error of type
// ErrPostIsDraft.
-func RenderMarkdown(md []byte, withMetadata bool) (geminiText []byte, metadata HugoMetadata, err error) {
+func RenderMarkdown(md []byte, metadataSetting MetadataSetting) (geminiText []byte, metadata HugoMetadata, err error) {
var (
blockEnd int
yamlContent []byte
@@ -84,7 +95,7 @@ func RenderMarkdown(md []byte, withMetadata bool) (geminiText []byte, metadata H
parse:
ast := markdown.Parse(md, parser.NewWithExtensions(parser.CommonExtensions))
var geminiContent []byte
- if withMetadata && metadata.PostTitle != "" {
+ if metadataSetting == WithMetadata && metadata.PostTitle != "" {
geminiContent = markdown.Render(ast, gemini.NewRendererWithMetadata(metadata))
} else {
geminiContent = markdown.Render(ast, gemini.NewRenderer())