diff --git a/.drone.yml b/.drone.yml
new file mode 100644
index 0000000..a7ce8d7
--- /dev/null
+++ b/.drone.yml
@@ -0,0 +1,27 @@
+kind: pipeline
+name: build & release
+
+steps:
+- name: fetch tags
+ image: docker:git
+ commands:
+ - git fetch --tags
+ when:
+ event: tag
+- name: test
+ image: golang:1.14
+ commands:
+ - go test -v ./internal/gemini
+ when:
+ event:
+ exclude:
+ - tag
+- name: release
+ image: golang:1.15
+ environment:
+ GITEA_TOKEN:
+ from_secret: goreleaser_gitea_token
+ commands:
+ - curl -sL https://git.io/goreleaser | bash
+ when:
+ event: tag
diff --git a/.goreleaser.yml b/.goreleaser.yml
new file mode 100644
index 0000000..9a9370d
--- /dev/null
+++ b/.goreleaser.yml
@@ -0,0 +1,53 @@
+builds:
+- main: ./cmd/gmnhg
+ id: gmnhg
+ binary: gmnhg
+ env:
+ - CGO_ENABLED=0
+ goos:
+ - linux
+ - windows
+ - darwin
+ - freebsd
+ - openbsd
+ - netbsd
+- main: ./cmd/md2gmn
+ id: md2gmn
+ binary: md2gmn
+ env:
+ - CGO_ENABLED=0
+ goos:
+ - linux
+ - windows
+ - darwin
+ - freebsd
+ - openbsd
+ - netbsd
+archives:
+- replacements:
+ darwin: Darwin
+ linux: Linux
+ windows: Windows
+ freebsd: FreeBSD
+ openbsd: OpenBSD
+ netbsd: NetBSD
+ 386: i386
+ amd64: x86_64
+checksum:
+ name_template: 'checksums.txt'
+snapshot:
+ name_template: "{{ .Tag }}-next"
+changelog:
+ sort: asc
+ filters:
+ exclude:
+ - '^docs:'
+ - '^test:'
+
+release:
+ gitea:
+ owner: tdemin
+ name: gmnhg
+
+gitea_urls:
+ api: https://git.tdem.in/api/v1/
diff --git a/README.md b/README.md
index 136a9c7..cba9b90 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# Hugo-to-Gemini converter
+[![PkgGoDev](https://pkg.go.dev/badge/git.tdem.in/tdemin/gmnhg)](https://pkg.go.dev/git.tdem.in/tdemin/gmnhg)
+
This repo holds a converter of Hugo Markdown posts to
[text/gemini][Gemtext] (also named Gemtext in this README). The
converter is supposed to make people using [Hugo](https://gohugo.io)'s
@@ -9,10 +11,6 @@ simpler.
[Gemini]: https://gemini.circumlunar.space
[Gemtext]: https://gemini.circumlunar.space/docs/specification.html
-At this stage of development this repo contains the actual renderer
-(`internal/gemini`) and the `md2gmn` program that converts Markdown
-input to Gemtext and is supposed to facilitate testing.
-
The renderer is somewhat hasty, and is NOT supposed to be able to
convert the entirety of possible Markdown to Gemtext (as it's not
possible to do so, considering Gemtext is a lot simpler than Markdown),
@@ -20,10 +18,28 @@ but instead a selected subset of it, enough for conveying your mind in
Markdown.
The renderer uses the [gomarkdown][gomarkdown] library for parsing
-Markdown.
+Markdown. gomarkdown has a few quirks at this time, the most notable one
+being unable to parse links/images inside other links.
[gomarkdown]: https://github.com/gomarkdown/markdown
+## gmnhg
+
+This program converts Hugo Markdown content files from `content/` in
+accordance with templates found in `gmnhg/` to the output dir. It
+also copies static files from `static/` to the output dir.
+
+For more details about the rendering process, see the
+[doc](cmd/gmnhg/main.go) attached to the program.
+
+```
+Usage of gmnhg:
+ -output string
+ output directory (will be created if missing) (default "output/")
+ -working string
+ working directory (defaults to current directory)
+```
+
## md2gmn
This program reads Markdown input from either text file (if `-f
@@ -35,15 +51,11 @@ Usage of md2gmn:
input file
```
-## TODO
-
-+ [x] convert Markdown text to Gemtext
-+ [ ] prepend contents of YAML front matter to Gemtext data
-+ [ ] render all Hugo content files to Gemtext in accordance with front
- matter data and Hugo config
+md2gmn is mainly made to facilitate testing the Gemtext renderer but
+can be used as a standalone program as well.
## License
This program is redistributed under the terms and conditions of the GNU
-General Public License, more specifically under version 3 of the
-License. For details, see [COPYING](COPYING).
+General Public License, more specifically version 3 of the License. For
+details, see [COPYING](COPYING).
diff --git a/cmd/gmnhg/main.go b/cmd/gmnhg/main.go
index 67fb969..0f51589 100644
--- a/cmd/gmnhg/main.go
+++ b/cmd/gmnhg/main.go
@@ -1,8 +1,330 @@
-// 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 (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.
+//
+// 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.
+//
+// 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.
+//
+// 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.
+//
+// One might want to ignore _index.gmi.md files with the following Hugo
+// config option in config.toml:
+//
+// ignoreFiles = [ "_index\\.gmi\\.md$" ]
package main
-func main() {
- println("in development")
+import (
+ "bytes"
+ "errors"
+ "flag"
+ "io"
+ "io/ioutil"
+ "os"
+ "path"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "text/template"
+
+ gemini "git.tdem.in/tdemin/gmnhg"
+)
+
+const (
+ defaultTemplate = "single"
+ indexMdFilename = "_index.gmi.md"
+ indexFilename = "index.gmi"
+)
+
+const (
+ contentBase = "content/"
+ templateBase = "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" || n == indexMdFilename {
+ return nil
+ }
+ fileContent, err := ioutil.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ gemText, metadata, err := gemini.RenderMarkdown(fileContent, gemini.WithoutMetadata)
+ // 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 := 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()))
+ }
+ }
+
+ var singleTemplate = defaultSingleTemplate
+ if tmpl, hasTmpl := templates["single"]; 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.WithoutMetadata)
+ 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.WithoutMetadata)
+ 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 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..050cb6b
--- /dev/null
+++ b/cmd/gmnhg/templates.go
@@ -0,0 +1,66 @@
+// 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
+
+{{ with .Content }}{{ printf "%s" . -}}{{ end }}
+{{ 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 5a30380..f5b26b3 100644
--- a/cmd/md2gmn/main.go
+++ b/cmd/md2gmn/main.go
@@ -13,7 +13,8 @@
// You should have received a copy of the GNU General Public License
// along with gmnhg. If not, see .
-// md2gmn converts Markdown text files to text/gemini.
+// md2gmn converts Markdown text files to text/gemini. It panics on
+// invalid input.
package main
import (
@@ -46,5 +47,10 @@ func main() {
panic(err)
}
- os.Stdout.Write(gemini.RenderMarkdown(text))
+ geminiContent, _, err := gemini.RenderMarkdown(text, gemini.WithMetadata)
+ if err != nil {
+ panic(err)
+ }
+
+ os.Stdout.Write(geminiContent)
}
diff --git a/go.mod b/go.mod
index 8f09c36..19fa428 100644
--- a/go.mod
+++ b/go.mod
@@ -2,4 +2,7 @@ module git.tdem.in/tdemin/gmnhg
go 1.15
-require github.com/gomarkdown/markdown v0.0.0-20201024011455-45c732cc8a6b
+require (
+ github.com/gomarkdown/markdown v0.0.0-20201024011455-45c732cc8a6b
+ gopkg.in/yaml.v2 v2.3.0
+)
diff --git a/go.sum b/go.sum
index 6b74de0..08d7486 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,6 @@
github.com/gomarkdown/markdown v0.0.0-20201024011455-45c732cc8a6b h1:Om9FdD4lzIJELyJxwr9EWSjaG6GMUNS3iebnhrGevhI=
github.com/gomarkdown/markdown v0.0.0-20201024011455-45c732cc8a6b/go.mod h1:aii0r/K0ZnHv7G0KF7xy1v0A7s2Ljrb5byB7MO5p6TU=
golang.org/dl v0.0.0-20190829154251-82a15e2f2ead/go.mod h1:IUMfjQLJQd4UTqG1Z90tenwKoCX93Gn3MAQJMOSBsDQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/internal/gemini/renderer.go b/internal/gemini/renderer.go
index c6059d1..4731475 100644
--- a/internal/gemini/renderer.go
+++ b/internal/gemini/renderer.go
@@ -18,10 +18,11 @@
package gemini
import (
- "bufio"
- "bytes"
"fmt"
"io"
+ "regexp"
+ "strings"
+ "time"
"github.com/gomarkdown/markdown/ast"
)
@@ -35,42 +36,38 @@ var (
itemIndent = []byte{'\t'}
)
+var meaningfulCharsRegex = regexp.MustCompile(`\A[\s]+\z`)
+
+const timestampFormat = "2006-01-02 15:04"
+
+// Metadata provides data necessary for proper post rendering.
+type Metadata interface {
+ Title() string
+ Date() time.Time
+}
+
// Renderer implements markdown.Renderer.
-type Renderer struct{}
+type Renderer struct {
+ Metadata Metadata
+}
// NewRenderer returns a new Renderer.
func NewRenderer() Renderer {
return Renderer{}
}
+// NewRendererWithMetadata returns a new Renderer initialized with post
+// metadata.
+func NewRendererWithMetadata(m Metadata) Renderer {
+ return Renderer{Metadata: m}
+}
+
func (r Renderer) link(w io.Writer, node *ast.Link, entering bool) {
if entering {
w.Write(linkPrefix)
w.Write(node.Destination)
- for _, child := range node.Children {
- if l := child.AsLeaf(); l != nil {
- w.Write(space)
- w.Write(l.Literal)
- }
- }
- }
-}
-
-func (r Renderer) linkText(w io.Writer, node *ast.Link) {
- for _, text := range node.Children {
- // TODO: Renderer.linkText: link can contain subblocks
- if l := text.AsLeaf(); l != nil {
- w.Write(l.Literal)
- }
- }
-}
-
-func (r Renderer) imageText(w io.Writer, node *ast.Image) {
- for _, text := range node.Children {
- // TODO: Renderer.imageText: link can contain subblocks
- if l := text.AsLeaf(); l != nil {
- w.Write(l.Literal)
- }
+ w.Write(space)
+ r.text(w, node)
}
}
@@ -78,15 +75,8 @@ func (r Renderer) image(w io.Writer, node *ast.Image, entering bool) {
if entering {
w.Write(linkPrefix)
w.Write(node.Destination)
- for _, sub := range node.Container.Children {
- if l := sub.AsLeaf(); l != nil {
- // TODO: Renderer.image: Markdown technically allows for
- // links inside image titles, yet to think out how to
- // render that :thinking:
- w.Write(space)
- w.Write(l.Literal)
- }
- }
+ w.Write(space)
+ r.text(w, node)
}
}
@@ -95,16 +85,8 @@ func (r Renderer) blockquote(w io.Writer, node *ast.BlockQuote, entering bool) {
// ideally to be merged with paragraph
if entering {
if para, ok := node.Children[0].(*ast.Paragraph); ok {
- for _, subnode := range para.Children {
- if l := subnode.AsLeaf(); l != nil {
- reader := bufio.NewScanner(bytes.NewBuffer(l.Literal))
- for reader.Scan() {
- w.Write(quotePrefix)
- w.Write(reader.Bytes())
- w.Write(lineBreak)
- }
- }
- }
+ w.Write(quotePrefix)
+ r.text(w, para)
}
}
}
@@ -132,56 +114,57 @@ func (r Renderer) paragraph(w io.Writer, node *ast.Paragraph, entering bool) (no
linkStack := make([]ast.Node, 0, len(children))
// current version of gomarkdown/markdown finds an empty
// *ast.Text element before links/images, breaking the heuristic
- onlyElementWithGoMarkdownFix := func() bool {
- if len(node.Children) > 1 {
- firstChild := node.Children[0]
- _, elementIsText := firstChild.(*ast.Text)
- asLeaf := firstChild.AsLeaf()
- if elementIsText && asLeaf != nil && len(asLeaf.Literal) == 0 {
- children = children[1:]
- return true
- }
+ if len(children) >= 2 {
+ firstChild := children[0]
+ _, elementIsText := firstChild.(*ast.Text)
+ asLeaf := firstChild.AsLeaf()
+ if elementIsText && asLeaf != nil && len(asLeaf.Literal) == 0 {
+ children = children[1:]
}
- return false
- }()
- onlyElement := len(children) == 1 || onlyElementWithGoMarkdownFix
- onlyElementIsLink := func() bool {
- if len(children) >= 1 {
- if _, ok := children[0].(*ast.Link); ok {
- return true
+ }
+ linksOnly := func() bool {
+ for _, child := range children {
+ if _, ok := child.(*ast.Link); ok {
+ continue
}
- if _, ok := children[0].(*ast.Image); ok {
- return true
+ if _, ok := child.(*ast.Image); ok {
+ continue
}
+ if child, ok := child.(*ast.Text); ok {
+ // any meaningful text?
+ if meaningfulCharsRegex.Find(child.Literal) == nil {
+ return false
+ }
+ continue
+ }
+ return false
}
- return false
+ return true
}()
- noNewLine = onlyElementIsLink
+ noNewLine = linksOnly
for _, child := range children {
// only render links text in the paragraph if they're
// combined with some other text on page
- if link, ok := child.(*ast.Link); ok {
- if !onlyElement {
- r.linkText(w, link)
+ switch child := child.(type) {
+ case *ast.Link, *ast.Image:
+ if !linksOnly {
+ r.text(w, child)
}
- linkStack = append(linkStack, link)
- }
- if image, ok := child.(*ast.Image); ok {
- if !onlyElement {
- r.imageText(w, image)
+ linkStack = append(linkStack, child)
+ case *ast.Text, *ast.Code, *ast.Emph, *ast.Strong, *ast.Del:
+ // the condition prevents text blocks consisting only of
+ // line breaks and spaces and such from rendering
+ if !linksOnly {
+ r.text(w, child)
}
- linkStack = append(linkStack, image)
- }
- if text, ok := child.(*ast.Text); ok {
- r.text(w, text)
}
}
- if !onlyElementIsLink {
+ if !linksOnly {
w.Write(lineBreak)
}
// render a links block after paragraph
if len(linkStack) > 0 {
- if !onlyElementIsLink {
+ if !linksOnly {
w.Write(lineBreak)
}
for _, link := range linkStack {
@@ -229,10 +212,7 @@ func (r Renderer) list(w io.Writer, node *ast.List, level int) {
}
para, ok := item.Children[0].(*ast.Paragraph)
if ok {
- text, ok := para.Children[0].(*ast.Text)
- if ok {
- r.text(w, text)
- }
+ r.text(w, para)
}
w.Write(lineBreak)
if l >= 2 {
@@ -244,8 +224,18 @@ func (r Renderer) list(w io.Writer, node *ast.List, level int) {
}
}
-func (r Renderer) text(w io.Writer, node *ast.Text) {
- w.Write(node.Literal)
+func (r Renderer) text(w io.Writer, node ast.Node) {
+ if node := node.AsLeaf(); node != nil {
+ // replace all newlines in text with spaces, allowing for soft
+ // wrapping; this is recommended as per Gemini spec p. 5.4.1
+ w.Write([]byte(strings.ReplaceAll(string(node.Literal), "\n", " ")))
+ return
+ }
+ if node := node.AsContainer(); node != nil {
+ for _, child := range node.Children {
+ r.text(w, child)
+ }
+ }
}
// RenderNode implements Renderer.RenderNode().
@@ -285,12 +275,15 @@ func (r Renderer) RenderNode(w io.Writer, node ast.Node, entering bool) ast.Walk
return ast.GoToNext
}
-// RenderHeader implements Renderer.RenderHeader().
+// RenderHeader implements Renderer.RenderHeader(). It renders metadata
+// at the top of the post if any has been provided.
func (r Renderer) RenderHeader(w io.Writer, node ast.Node) {
- // likely doesn't need any code
+ if r.Metadata != nil {
+ // TODO: Renderer.RenderHeader: check whether date is mandatory
+ // in Hugo
+ w.Write([]byte(fmt.Sprintf("# %s\n\n%s\n\n", r.Metadata.Title(), r.Metadata.Date().Format(timestampFormat))))
+ }
}
// RenderFooter implements Renderer.RenderFooter().
-func (r Renderer) RenderFooter(w io.Writer, node ast.Node) {
- // likely doesn't need any code either
-}
+func (r Renderer) RenderFooter(w io.Writer, node ast.Node) {}
diff --git a/render.go b/render.go
index 5bbe75e..bd07869 100644
--- a/render.go
+++ b/render.go
@@ -14,20 +14,94 @@
// along with gmnhg. If not, see .
// Package gemini provides functions to convert Markdown files to
-// Gemtext.
+// Gemtext. It supports the use of YAML front matter in Markdown.
package gemini
import (
+ "bytes"
+ "errors"
+ "fmt"
+ "time"
+
"git.tdem.in/tdemin/gmnhg/internal/gemini"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/parser"
+ "gopkg.in/yaml.v2"
)
-// RenderMarkdown converts Markdown text to text/gemini using gomarkdown.
-//
-// gomarkdown doesn't return any errors, nor does this function.
-func RenderMarkdown(md []byte) (geminiText []byte) {
- ast := markdown.Parse(md, parser.NewWithExtensions(parser.CommonExtensions))
- geminiContent := markdown.Render(ast, gemini.NewRenderer())
- return geminiContent
+// HugoMetadata implements gemini.Metadata, providing the bare minimum
+// of possible post props.
+type HugoMetadata struct {
+ PostTitle string `yaml:"title"`
+ PostIsDraft bool `yaml:"draft"`
+ PostLayout string `yaml:"layout"`
+ PostDate time.Time `yaml:"date"`
+}
+
+// Title returns post title.
+func (h HugoMetadata) Title() string {
+ return h.PostTitle
+}
+
+// Date returns post date.
+func (h HugoMetadata) Date() time.Time {
+ return h.PostDate
+}
+
+var yamlDelimiter = []byte("---\n")
+
+// ErrPostIsDraft indicates the post rendered is a draft and is not
+// 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.
+//
+// Only a subset of front matter data parsed by Hugo is included in the
+// final document. At this point it's just title and date.
+//
+// Draft posts are still rendered, but with an error of type
+// ErrPostIsDraft.
+func RenderMarkdown(md []byte, metadataSetting MetadataSetting) (geminiText []byte, metadata HugoMetadata, err error) {
+ var (
+ blockEnd int
+ yamlContent []byte
+ )
+ // only allow front matter at file start
+ if bytes.Index(md, yamlDelimiter) != 0 {
+ goto parse
+ }
+ blockEnd = bytes.Index(md[len(yamlDelimiter):], yamlDelimiter)
+ if blockEnd == -1 {
+ goto parse
+ }
+ yamlContent = md[len(yamlDelimiter) : blockEnd+len(yamlDelimiter)]
+ if err := yaml.Unmarshal(yamlContent, &metadata); err != nil {
+ return nil, metadata, fmt.Errorf("invalid front matter: %w", err)
+ }
+ md = md[blockEnd+len(yamlDelimiter)*2:]
+parse:
+ ast := markdown.Parse(md, parser.NewWithExtensions(parser.CommonExtensions))
+ var geminiContent []byte
+ if metadataSetting == WithMetadata && metadata.PostTitle != "" {
+ geminiContent = markdown.Render(ast, gemini.NewRendererWithMetadata(metadata))
+ } else {
+ geminiContent = markdown.Render(ast, gemini.NewRenderer())
+ }
+ if metadata.PostIsDraft {
+ return geminiContent, metadata, fmt.Errorf("%s: %w", metadata.PostTitle, ErrPostIsDraft)
+ }
+ return geminiContent, metadata, nil
}