Release v0.1.0
This commit is contained in:
commit
26fcc06075
10 changed files with 678 additions and 119 deletions
27
.drone.yml
Normal file
27
.drone.yml
Normal 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
53
.goreleaser.yml
Normal 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/
|
38
README.md
38
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).
|
||||
|
|
|
@ -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
66
cmd/gmnhg/templates.go
Normal 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 }}
|
||||
`)
|
|
@ -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
5
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
|
||||
)
|
||||
|
|
3
go.sum
3
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=
|
||||
|
|
|
@ -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) {}
|
||||
|
|
90
render.go
90
render.go
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue