Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Browse the repository at this point in the history
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.
- Loading branch information
Showing
4 changed files
with
364 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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). | ||
// | ||
// 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. | ||
// | ||
// TODO: it is yet to actually do that. | ||
// 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 | ||
|
||
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() { | ||
println("in development") | ||
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 }} | ||
`) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.