diff --git a/cmd/md2gmn/main.go b/cmd/md2gmn/main.go index 5a30380..3d7ed9a 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) + 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 d4abc01..5b1792e 100644 --- a/internal/gemini/renderer.go +++ b/internal/gemini/renderer.go @@ -22,6 +22,7 @@ import ( "bytes" "fmt" "io" + "time" "github.com/gomarkdown/markdown/ast" ) @@ -35,14 +36,30 @@ var ( itemIndent = []byte{'\t'} ) +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) @@ -290,12 +307,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..ab1d7f7 100644 --- a/render.go +++ b/render.go @@ -14,20 +14,64 @@ // 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" + "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"` + PostDate time.Time `yaml:"date"` +} + +func (h hugoMetadata) Title() string { + return h.PostTitle +} + +func (h hugoMetadata) Date() time.Time { + return h.PostDate +} + +var yamlDelimiter = []byte("---\n") + +// RenderMarkdown converts Markdown text to text/gemini using +// gomarkdown, appending Hugo YAML front matter data if any is present +// to the post header. +func RenderMarkdown(md []byte) (geminiText []byte, err error) { + var metadata hugoMetadata + if len(md) > len(yamlDelimiter)*2 { + // 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, 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 metadata.PostTitle != "" { + geminiContent = markdown.Render(ast, gemini.NewRendererWithMetadata(metadata)) + } else { + geminiContent = markdown.Render(ast, gemini.NewRenderer()) + } + return geminiContent, nil }