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())