Parse metadata in all Hugo formats

This makes gmnhg parse TOML/YAML/JSON/org-mode front matter in Hugo posts.

This also makes the library no longer render metadata in posts, removing
the API to do so. The metadata parsing code itself moves to
internal/gmnhg.

As the library no longer has a preset to include metadata in rendered
document, md2gmn will from now on silently discard front matter if it
encounters any.

Fixes #28. Unblocks #13.
This commit is contained in:
Timur Demin 2021-09-17 22:34:03 +05:00 committed by GitHub
parent 2de6e634d6
commit 9778ada128
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 265 additions and 129 deletions

View file

@ -102,17 +102,11 @@
// 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"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
@ -322,11 +316,13 @@ func main() {
if err != nil {
return err
}
gemText, metadata, err := gemini.RenderMarkdown(fileContent, gemini.Defaults)
content, metadata := gmnhg.ParseMetadata(fileContent)
// skip drafts from rendering
if errors.Is(err, gemini.ErrPostIsDraft) {
if metadata.IsDraft {
return nil
} else if err != nil {
}
gemText, err := gemini.RenderMarkdown(content, gemini.Defaults)
if err != nil {
return err
}
// skip headless leaves from rendering
@ -387,7 +383,7 @@ func main() {
// render posts to files
for fileName, post := range posts {
var tmpl = singleTemplate
if pl := post.Metadata.PostLayout; pl != "" {
if pl := post.Metadata.Layout; pl != "" {
t, ok := templates[pl]
if !ok {
// no point trying to render pages with no layout
@ -413,15 +409,17 @@ func main() {
if !hasTmpl {
continue
}
content, err := ioutil.ReadFile(path.Join(contentBase, dirname, indexMdFilename))
fileContent, 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) {
content, metadata := gmnhg.ParseMetadata(fileContent)
if metadata.IsDraft {
continue
} else if err != nil {
}
gemtext, err := gemini.RenderMarkdown(content, gemini.Defaults)
if err != nil {
panic(err)
}
cnt := map[string]interface{}{
@ -446,8 +444,9 @@ func main() {
if err != nil {
panic(err)
}
gemtext, _, err := gemini.RenderMarkdown(indexContent, gemini.Defaults)
if err != nil && !errors.Is(err, gemini.ErrPostIsDraft) {
content, _ := gmnhg.ParseMetadata(indexContent)
gemtext, err := gemini.RenderMarkdown(content, gemini.Defaults)
if err != nil {
panic(err)
}
buf := bytes.Buffer{}

View file

@ -13,8 +13,7 @@
// 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. It panics on
// invalid input.
// md2gmn converts Markdown text files to text/gemini.
package main
import (
@ -23,6 +22,7 @@ import (
"os"
gemini "github.com/tdemin/gmnhg"
"github.com/tdemin/gmnhg/internal/gmnhg"
)
var version = "v0+HEAD"
@ -56,7 +56,8 @@ func main() {
panic(err)
}
geminiContent, _, err := gemini.RenderMarkdown(text, gemini.WithMetadata)
content, _ := gmnhg.ParseMetadata(text)
geminiContent, err := gemini.RenderMarkdown(content, gemini.Defaults)
if err != nil {
panic(err)
}

1
go.mod
View file

@ -7,6 +7,7 @@ require (
github.com/Masterminds/sprig/v3 v3.2.2
github.com/gomarkdown/markdown v0.0.0-20210514010506-3b9f47219fe7
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/niklasfasching/go-org v1.5.0
github.com/olekukonko/tablewriter v0.0.5
gopkg.in/yaml.v2 v2.4.0
)

24
go.sum
View file

@ -6,9 +6,16 @@ github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030I
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/Masterminds/sprig/v3 v3.2.2 h1:17jRggJu518dr3QaafizSXOjKYp94wKfABxUmyxvxX8=
github.com/Masterminds/sprig/v3 v3.2.2/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk=
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI=
github.com/alecthomas/chroma v0.8.2/go.mod h1:sko8vR34/90zvl5QdcUdvzL3J8NKjAUx9va9jPuFNoM=
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0=
github.com/alecthomas/kong v0.2.4/go.mod h1:kQOmtJgV+Lb4aj+I2LEn40cbtawdWJ9Y8QLq+lElKxE=
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/gomarkdown/markdown v0.0.0-20210514010506-3b9f47219fe7 h1:oKYOfNR7Hp6XpZ4JqolL5u642Js5Z0n7psPVl+S5heo=
github.com/gomarkdown/markdown v0.0.0-20210514010506-3b9f47219fe7/go.mod h1:aii0r/K0ZnHv7G0KF7xy1v0A7s2Ljrb5byB7MO5p6TU=
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
@ -17,6 +24,8 @@ github.com/huandu/xstrings v1.3.1 h1:4jgBlKK6tLKFvO8u5pmYjG91cqytmDCDvGh7ECVFfFs
github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA=
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
@ -24,18 +33,24 @@ github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMK
github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY=
github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/niklasfasching/go-org v1.5.0 h1:V8IwoSPm/d61bceyWFxxnQLtlvNT+CjiYIhtZLdnMF0=
github.com/niklasfasching/go-org v1.5.0/go.mod h1:sSb8ylwnAG+h8MGFDB3R1D5bxf8wA08REfhjShg3kjA=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
golang.org/dl v0.0.0-20190829154251-82a15e2f2ead/go.mod h1:IUMfjQLJQd4UTqG1Z90tenwKoCX93Gn3MAQJMOSBsDQ=
@ -43,9 +58,18 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20200414173820-0848c9571904 h1:bXoxMPcSLOq08zI3/c5dEBT6lE4eh+jOh886GHrn6V8=
golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b h1:iFwSg7t5GZmB/Q5TjiEAsdoLDrdJRC1RiF2WhuV29Qw=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

121
internal/gmnhg/org.go Normal file
View file

@ -0,0 +1,121 @@
package gmnhg
import (
"bytes"
"fmt"
"reflect"
"regexp"
"strconv"
"strings"
"github.com/niklasfasching/go-org/org"
)
// for strings "true", "false", and int/float numbers will try to
// convert them to Go values; will simply return value on fail or any
// other kind of input
func parseValue(value interface{}) interface{} {
switch value := value.(type) {
case string:
num, err := strconv.Atoi(value)
if err == nil {
return num
}
float, err := strconv.ParseFloat(value, 64)
if err == nil {
return float
}
boolean, err := strconv.ParseBool(value)
if err == nil {
return boolean
}
}
return value
}
// for key "key" will set either map key "key" or struct field tagged
// `tag:"key"` with value; expects a pointer
func reflectSetKey(mapOrStruct interface{}, tag, key string, value interface{}) (err error) {
defer func() {
if e := recover(); e != nil {
err = fmt.Errorf("recovered from panic: %v", e)
}
}()
v := reflect.ValueOf(mapOrStruct).Elem()
switch kind := v.Kind(); kind {
case reflect.Map:
v.SetMapIndex(reflect.ValueOf(key), reflect.ValueOf(value))
case reflect.Struct:
var fieldName string
t := v.Type()
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
v, ok := field.Tag.Lookup(tag)
if !ok {
continue
}
if v != key {
continue
}
fieldName = field.Name
}
if fieldName == "" {
return fmt.Errorf("cannot find tag %v with key %v in struct", tag, key)
}
v.FieldByName(fieldName).Set(reflect.ValueOf(parseValue(value)))
default:
return fmt.Errorf("cannot set key of %v", kind.String())
}
return nil
}
func unmarshalORG(data []byte, p interface{}) (err error) {
parser := org.New()
document := parser.Parse(bytes.NewReader(data), "")
if document.Error != nil {
return document.Error
}
for k, v := range document.BufferSettings {
var (
key string = k
value interface{} = v
)
if strings.HasSuffix(k, "[]") {
key = k[:len(k)-2]
value = strings.Fields(v)
} else if k == "tags" || k == "categories" || k == "aliases" {
value = strings.Fields(v)
} else if k == "date" {
value = parseORGDate(v)
}
if err := reflectSetKey(p, "org", strings.ToLower(key), value); err != nil {
return err
}
}
return nil
}
// Some Org parsing code below was originally taken from Hugo and was
// tweaked for gmnhg purposes.
//
// Copyright 2018 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License"); you
// may not use this file except in compliance with the License. You may
// obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
// implied. See the License for the specific language governing
// permissions and limitations under the License.
var orgDateRegex = regexp.MustCompile(`[<\[](\d{4}-\d{2}-\d{2}) .*[>\]]`)
func parseORGDate(s string) string {
if m := orgDateRegex.FindStringSubmatch(s); m != nil {
return m[1]
}
return s
}

View file

@ -1,10 +1,18 @@
package gmnhg
import gemini "github.com/tdemin/gmnhg"
import (
"bytes"
"encoding/json"
"regexp"
"time"
"github.com/BurntSushi/toml"
"gopkg.in/yaml.v2"
)
type Post struct {
Post []byte
Metadata gemini.HugoMetadata
Metadata Metadata
Link string
}
@ -16,7 +24,7 @@ func (p Posts) Len() int {
}
func (p Posts) Less(i, j int) bool {
return p[i].Metadata.PostDate.Before(p[j].Metadata.PostDate)
return p[i].Metadata.Date.Before(p[j].Metadata.Date)
}
func (p Posts) Swap(i, j int) {
@ -24,3 +32,76 @@ func (p Posts) Swap(i, j int) {
p[i] = p[j]
p[j] = t
}
// Metadata contains all recognized Hugo properties.
type Metadata struct {
Title string `yaml:"title" toml:"title" json:"title" org:"title"`
IsDraft bool `yaml:"draft" toml:"draft" json:"draft" org:"draft"`
Layout string `yaml:"layout" toml:"layout" json:"layout" org:"layout"`
Date time.Time `yaml:"date" toml:"date" json:"date" org:"date"`
Summary string `yaml:"summary" toml:"summary" json:"summary" org:"summary"`
IsHeadless bool `yaml:"headless" toml:"headless" json:"headless" org:"headless"`
}
var (
yamlDelimiter = []byte("---\n")
tomlDelimiter = []byte("+++\n")
jsonObjectRegex = regexp.MustCompile(`\A(\{[\s\S]*\})\n\n`)
orgModeRegex = regexp.MustCompile(`\A((?:#\+\w+: ?\S*\n)*)`)
)
// ParseMetadata extracts TOML/JSON/YAML/org-mode format front matter
// from Markdown text. If no metadata is found, markdown will be equal
// to source.
//
// TOML front matter is identified as +++ symbols at the very start of
// the text, followed by TOML content, followed by another +++ (YAML is
// the same, but with ---). JSON front matter is identified as a JSON
// object followed by two newline symbols. org-mode front matter is
// identified as a set of #+KEY: VALUE lines, the first line started
// with anything else but #+ ends the front matter.
func ParseMetadata(source []byte) (markdown []byte, metadata Metadata) {
var (
blockEnd int
metadataContent []byte
)
markdown = source
// block start is always 0, as front matter is only permitted at the
// very start of the file
if bytes.Index(source, yamlDelimiter) == 0 {
blockEnd = bytes.Index(source[len(yamlDelimiter):], yamlDelimiter)
if blockEnd == -1 {
return
}
metadataContent = source[len(yamlDelimiter) : blockEnd+len(yamlDelimiter)]
if err := yaml.Unmarshal(metadataContent, &metadata); err != nil {
return
}
markdown = source[blockEnd+len(yamlDelimiter)*2:]
} else if bytes.Index(source, tomlDelimiter) == 0 {
blockEnd = bytes.Index(source[len(tomlDelimiter):], tomlDelimiter)
if blockEnd == -1 {
return
}
metadataContent = source[len(tomlDelimiter) : blockEnd+len(tomlDelimiter)]
if err := toml.Unmarshal(metadataContent, &metadata); err != nil {
return
}
markdown = source[blockEnd+len(yamlDelimiter)*2:]
} else if match := jsonObjectRegex.FindIndex(source); match != nil {
blockEnd = match[1]
metadataContent = source[:blockEnd]
if err := json.Unmarshal(metadataContent, &metadata); err != nil {
return
}
markdown = source[blockEnd+1:] // JSON end + \n\n - 1
} else if match := orgModeRegex.FindIndex(source); match != nil {
blockEnd = match[1]
metadataContent = source[:blockEnd]
if err := unmarshalORG(metadataContent, &metadata); err != nil {
return
}
markdown = source[blockEnd:]
}
return
}

View file

@ -13,9 +13,9 @@
// You should have received a copy of the GNU General Public License
// along with gmnhg. If not, see <https://www.gnu.org/licenses/>.
// Package gemini contains an implementation of markdown => text/gemini
// Package renderer contains an implementation of markdown => text/gemini
// renderer for github.com/gomarkdown/markdown.
package gemini
package renderer
import (
"bytes"
@ -23,7 +23,6 @@ import (
"io"
"regexp"
"strings"
"time"
"github.com/gomarkdown/markdown/ast"
"github.com/olekukonko/tablewriter"
@ -52,30 +51,14 @@ var (
// matches a FULL string that contains no non-whitespace characters
var emptyLineRegex = 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 {
Metadata Metadata
}
type Renderer struct{}
// 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 getNodeDelimiter(node ast.Node) []byte {
switch node.(type) {
case *ast.Code:
@ -528,15 +511,8 @@ func (r Renderer) RenderNode(w io.Writer, node ast.Node, entering bool) ast.Walk
return ast.GoToNext
}
// 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) {
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))))
}
}
// RenderHeader implements Renderer.RenderHeader().
func (r Renderer) RenderHeader(w io.Writer, node ast.Node) {}
// RenderFooter implements Renderer.RenderFooter().
func (r Renderer) RenderFooter(w io.Writer, node ast.Node) {}

View file

@ -14,112 +14,45 @@
// along with gmnhg. If not, see <https://www.gnu.org/licenses/>.
// Package gemini provides functions to convert Markdown files to
// Gemtext. It supports the use of YAML front matter in Markdown.
// Gemtext.
package gemini
import (
"bytes"
"errors"
"fmt"
"time"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/parser"
"github.com/tdemin/gmnhg/internal/gemini"
"gopkg.in/yaml.v2"
"github.com/tdemin/gmnhg/internal/renderer"
)
// 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"`
PostSummary string `yaml:"summary"`
IsHeadless bool `yaml:"headless"`
}
// 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")
trailing = []byte("\n\n")
)
// ErrPostIsDraft indicates the post rendered is a draft and is not
// supposed to be rendered.
var ErrPostIsDraft = errors.New("post is draft")
// Settings is a bitmask for renderer preferences.
type Settings uint
// Has uses AND to check whether a flag is set.
// Has returns true if a flag or a set of flags are all set.
func (s Settings) Has(setting Settings) bool {
return (s & setting) != 0
return (s & setting) == setting
}
const (
// Defaults simply renders the document.
Defaults Settings = 0
// WithMetadata indicates that the metadata should be included in
// the text produced by the renderer.
WithMetadata Settings = 1 << iota
)
var trailing = []byte("\n\n")
// RenderMarkdown converts Markdown text to Gemtext using gomarkdown. It
// appends Hugo YAML front matter data to the post header if
// WithMetadata is set.
//
// 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, settings Settings) (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:
// ignores front matter if any has been provided in the text.
func RenderMarkdown(md []byte, settings Settings) (geminiText []byte, err error) {
ast := markdown.Parse(md, parser.NewWithExtensions(parser.CommonExtensions|
parser.NoEmptyLineBeforeBlock|
parser.Footnotes))
var content []byte
if settings.Has(WithMetadata) && metadata.PostTitle != "" {
content = markdown.Render(ast, gemini.NewRendererWithMetadata(metadata))
} else {
content = markdown.Render(ast, gemini.NewRenderer())
}
content := markdown.Render(ast, renderer.NewRenderer())
// strip trailing newlines if any
for li := bytes.LastIndex(content, trailing); li != -1; li = bytes.LastIndex(content, trailing) {
if li != len(content)-len(trailing) {
break
}
content = content[:len(content)-1]
}
if metadata.PostIsDraft {
return content, metadata, fmt.Errorf("%s: %w", metadata.PostTitle, ErrPostIsDraft)
}
return content, metadata, nil
return content, nil
}