Release v0.1.0

This commit is contained in:
Timur Demin 2020-11-20 16:23:39 +05:00
commit 26fcc06075
No known key found for this signature in database
GPG key ID: 9EDF3F9D9286FA20
10 changed files with 678 additions and 119 deletions

27
.drone.yml Normal file
View file

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

53
.goreleaser.yml Normal file
View file

@ -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/

View file

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

View file

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

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

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

View file

@ -13,7 +13,8 @@
// You should have received a copy of the GNU General Public License
// along with gmnhg. If not, see <https://www.gnu.org/licenses/>.
// 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)
}

5
go.mod
View file

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

3
go.sum
View file

@ -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=

View file

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

View file

@ -14,20 +14,94 @@
// along with gmnhg. If not, see <https://www.gnu.org/licenses/>.
// 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
}