hugo-to-gemini/cmd/gmnhg/main.go
mntn c2f2386a3d
Add support for branch and leaf bundles
This patch adds support for Hugo's branch and leaf bundles: https://gohugo.io/content-management/page-bundles/

Content directories can now have nested subdirectories, and content from each level will be converted to Gemini. If a subdirectory has a file named index.md (or any index.* file per the footnote on the Hugo page) then it becomes a leaf bundle. For leaf bundles, only the generated index.gmi file will be passed up to parent directories as a page. The rest will be treated as "resources" that are not normally visible to pages in parent folders.

Each parent _index.md receives a list of pages from all nested subdirectories (except for the aforementioned leaf resource pages). This allows for content "rollup" pages. For instance, /series could show all pages under /series/first-season and /series/second-season, while allowing each subfolder to have its own more limited index. This also allows for "clean" URLs, as leaf bundles can be used to make pages that are basically just an index.md page under a named folder. You can then use the trimSuffix function (from sprig) in your gotmpl templates to clean up any links, by stripping "/index.md".

This patch also adds the copying of non-Markdown resource files from content directories, so that bundled resources can be pulled in. Theoretically if someone puts carefully named .gmi files in their content folder then this could cause a name collision, but you'd almost have to try to make that happen, so I didn't put in any checks for it. If this happens then any generated files with the same name are simply overwritten, so there's no serious harm done.

Hugo's leaf bundles support a special front matter attribute, "headless", which if set to true will cause the index.md file to be "invisible" and it will not be passed up to the parent. This is not currently implemented.
2021-08-17 22:59:11 +05:00

423 lines
12 KiB
Go

// 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 (layout file names are relative to 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.
// Its content is taken from _index.gmi.md in that dir. If there's no
// matching template or no _index.gmi.md, the index won't be rendered.
//
// Templates for subdirectories are placed in subfolders under top/.
// For example, a template for an index at series/first/_index.gmi.md
// should be placed at top/series/first.gotmpl.
//
// 3. The very top index.gmi is generated from index.gotmpl and
// top-level _index.gmi.
//
// The program will then copy static files from static/ directory to the
// output dir. Page resources (non-Markdown files) will also be copied
// from the content/ directory as-is, without further modification.
//
// 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), .Dirname, which is
// directory name relative to content dir, and .Content, which is
// rendered from directory's _index.gmi.md.
//
// Directory indices are passed all posts from subdirectories (branch
// and leaf bundles), with the exception of leaf resource pages.
// This allows for roll-up indices.
//
// 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, and .Content, which is
// rendered from top-level _index.gmi.md.
//
// This program provides some extra template functions, documented in
// templates.go. Template functions from sprig are also available
// (https://github.com/Masterminds/sprig); see the sprig documentation
// for more details.
//
// One might want to ignore _index.gmi.md files with the following Hugo
// config option in config.toml:
//
// ignoreFiles = [ "_index\\.gmi\\.md$" ]
//
// Limitations:
//
// * For now, the program will only recognize YAML front matter, while
// Hugo supports it in TOML, YAML, JSON, and org-mode formats.
package main
import (
"bytes"
"errors"
"flag"
"fmt"
"io"
"io/ioutil"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"text/template"
gemini "github.com/tdemin/gmnhg"
)
const (
defaultPageTemplate = "single"
indexMdFilename = "_index.gmi.md"
indexFilename = "index.gmi"
)
const (
contentBase = "content/"
templateBase = "gmnhg/"
staticBase = "static/"
outputBase = "output/"
)
var (
tmplNameRegex = regexp.MustCompile("^" + templateBase + `([\w-_ /]+)\.gotmpl$`)
leafIndexRegex = regexp.MustCompile("^" + contentBase + `([\w-_ /]+)/index\.[\w]+$`)
pagePathRegex = regexp.MustCompile("^" + contentBase + `([\w-_ /]+)/([\w-_ ]+)\.md$`)
)
var hugoConfigFiles = []string{"config.toml", "config.yaml", "config.json"}
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 hasSubPath(paths []string, path string) bool {
for _, p := range paths {
if strings.HasPrefix(path, p + "/") {
return true
}
}
return false
}
var version = "v0+HEAD"
func main() {
var (
outputDir, workingDir string
isVersionCmd bool
)
flag.StringVar(&outputDir, "output", outputBase, "output directory (will be created if missing)")
flag.StringVar(&workingDir, "working", "", "working directory (defaults to current directory)")
flag.BoolVar(&isVersionCmd, "version", false, "display version")
flag.Parse()
if isVersionCmd {
println("gmnhg", version)
return
}
if workingDir != "" {
if err := os.Chdir(workingDir); err != nil {
panic(err)
}
}
configFound := false
for _, filename := range hugoConfigFiles {
if fileInfo, err := os.Stat(filename); !(os.IsNotExist(err) || fileInfo.IsDir()) {
configFound = true
break
}
}
if !configFound {
panic(fmt.Errorf("no Hugo config in %v found; not in a Hugo site dir?", hugoConfigFiles))
}
// 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(defineFuncMap()).Parse(string(contents))
if err != nil {
return err
}
templates[tmplName] = tmpl
return nil
}); err != nil {
panic(err)
}
}
// collect leaf node paths (directories containing an index.* file)
leafIndexPaths := []string{}
if err := filepath.Walk(contentBase, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
if matches := leafIndexRegex.FindStringSubmatch(path); matches != nil {
leafIndexPaths = append(leafIndexPaths, contentBase + matches[1])
}
return nil
}); err != nil {
panic(err)
}
// render posts to Gemtext and collect top level posts data
posts := make(map[string]*post)
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" || n == indexMdFilename {
return nil
}
fileContent, err := ioutil.ReadFile(path)
if err != nil {
return err
}
gemText, metadata, err := gemini.RenderMarkdown(fileContent, gemini.Defaults)
// skip drafts from rendering
if errors.Is(err, gemini.ErrPostIsDraft) {
return nil
} else if err != nil {
return err
}
key := strings.TrimPrefix(strings.TrimSuffix(path, ".md"), contentBase) + ".gmi"
p := post{
Post: gemText,
Link: key,
Metadata: metadata,
}
posts[key] = &p
if matches := pagePathRegex.FindStringSubmatch(path); matches != nil {
dirs := strings.Split(matches[1], "/")
// only include leaf resources pages in leaf index
if info.Name() != "index.md" && hasSubPath(leafIndexPaths, path) {
topLevelPosts[matches[1]] = append(topLevelPosts[matches[1]], &p)
} else {
// include normal pages in all subdirectory indices
for i, dir := range dirs {
if i > 0 {
dirs[i] = dirs[i - 1] + "/" + dir
}
}
for _, dir := range dirs {
topLevelPosts[dir] = append(topLevelPosts[dir], &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()))
}
}
var singleTemplate = defaultSingleTemplate
if tmpl, hasTmpl := templates[defaultPageTemplate]; hasTmpl {
singleTemplate = tmpl
}
// render posts to files
for fileName, post := range posts {
var tmpl = singleTemplate
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
}
content, err := ioutil.ReadFile(path.Join(contentBase, dirname, indexMdFilename))
if err != nil {
// skip unreadable index files
continue
}
gemtext, _, err := gemini.RenderMarkdown(content, gemini.Defaults)
if errors.Is(err, gemini.ErrPostIsDraft) {
continue
} else if err != nil {
panic(err)
}
cnt := map[string]interface{}{
"Posts": posts,
"Dirname": dirname,
"Content": gemtext,
}
buf := bytes.Buffer{}
if err := tmpl.Execute(&buf, cnt); err != nil {
panic(err)
}
if err := writeFile(path.Join(outputDir, dirname, indexFilename), buf.Bytes()); err != nil {
panic(err)
}
}
// render index page
var indexTmpl = defaultIndexTemplate
if t, hasIndexTmpl := templates["index"]; hasIndexTmpl {
indexTmpl = t
}
indexContent, err := ioutil.ReadFile(path.Join(contentBase, indexMdFilename))
if err != nil {
panic(err)
}
gemtext, _, err := gemini.RenderMarkdown(indexContent, gemini.Defaults)
if err != nil && !errors.Is(err, gemini.ErrPostIsDraft) {
panic(err)
}
buf := bytes.Buffer{}
cnt := map[string]interface{}{"PostData": topLevelPosts, "Content": gemtext}
if err := indexTmpl.Execute(&buf, cnt); err != nil {
panic(err)
}
if err := writeFile(path.Join(outputDir, indexFilename), buf.Bytes()); err != nil {
panic(err)
}
// copy page resources to output dir
if err := filepath.Walk(contentBase, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() || strings.HasSuffix(info.Name(), ".md") {
return nil
}
return copyFile(path.Join(outputDir, strings.TrimPrefix(p, contentBase)), p)
}); 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 os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
if info.IsDir() {
return nil
}
return copyFile(path.Join(outputDir, strings.TrimPrefix(p, staticBase)), p)
}); err != nil {
panic(err)
}
}