Implement gmnhg

gmnhg is the new program that generates a Gemini site from Hugo site
content. It reads its input from content/, static/, and layouts/gmnhg/.
Its output by default goes to output/.

More doc is available in the program doc header.
This commit is contained in:
Timur Demin 2020-11-11 23:15:26 +05:00
parent 8226a20880
commit 1a0e61dc3f
No known key found for this signature in database
GPG key ID: 9EDF3F9D9286FA20
4 changed files with 365 additions and 7 deletions

View file

@ -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 <https://www.gnu.org/licenses/>.
// 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)
}
}

65
cmd/gmnhg/templates.go Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
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 }}
`)

View file

@ -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)
}

View file

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