diff --git a/adapter/markdown/markdown.go b/adapter/markdown/markdown.go index c103681..223178c 100644 --- a/adapter/markdown/markdown.go +++ b/adapter/markdown/markdown.go @@ -2,12 +2,15 @@ package markdown import ( "bufio" + "regexp" "strings" "github.com/mickael-menu/zk/core/note" "github.com/mickael-menu/zk/util/opt" "github.com/yuin/goldmark" + meta "github.com/yuin/goldmark-meta" "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/text" ) @@ -19,7 +22,11 @@ type Parser struct { // NewParser creates a new Markdown Parser. func NewParser() *Parser { return &Parser{ - md: goldmark.New(), + md: goldmark.New( + goldmark.WithExtensions( + meta.Meta, + ), + ), } } @@ -28,22 +35,37 @@ func (p *Parser) Parse(source string) (note.Content, error) { out := note.Content{} bytes := []byte(source) - root := p.md.Parser().Parse(text.NewReader(bytes)) - title, titleNode, err := parseTitle(root, bytes) + context := parser.NewContext() + root := p.md.Parser().Parse( + text.NewReader(bytes), + parser.WithContext(context), + ) + + frontmatter, err := parseFrontmatter(context, bytes) + if err != nil { + return out, err + } + + title, bodyStart, err := parseTitle(frontmatter, root, bytes) if err != nil { return out, err } out.Title = title - out.Body = parseBody(titleNode, bytes) + out.Body = parseBody(bodyStart, bytes) out.Lead = parseLead(out.Body) return out, nil } // parseTitle extracts the note title with its node. -func parseTitle(root ast.Node, source []byte) (title opt.String, node ast.Node, err error) { +func parseTitle(frontmatter frontmatter, root ast.Node, source []byte) (title opt.String, bodyStart int, err error) { + if title = frontmatter.getString("title", "Title"); !title.IsNull() { + bodyStart = frontmatter.end + return + } + var titleNode *ast.Heading err = ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if heading, ok := n.(*ast.Heading); ok && entering && @@ -62,24 +84,20 @@ func parseTitle(root ast.Node, source []byte) (title opt.String, node ast.Node, } if titleNode != nil { - node = titleNode title = opt.NewNotEmptyString(string(titleNode.Text(source))) - } - return -} -// parseBody extracts the whole content after the title. -func parseBody(titleNode ast.Node, source []byte) opt.String { - start := 0 - if titleNode != nil { if lines := titleNode.Lines(); lines.Len() > 0 { - start = lines.At(lines.Len() - 1).Stop + bodyStart = lines.At(lines.Len() - 1).Stop } } + return +} +// parseBody extracts the whole content after the title. +func parseBody(startIndex int, source []byte) opt.String { return opt.NewNotEmptyString( strings.TrimSpace( - string(source[start:]), + string(source[startIndex:]), ), ) } @@ -97,3 +115,38 @@ func parseLead(body opt.String) opt.String { return opt.NewNotEmptyString(strings.TrimSpace(lead)) } + +// frontmatter contains metadata parsed from a YAML frontmatter. +type frontmatter struct { + values map[string]interface{} + start int + end int +} + +var frontmatterRegex = regexp.MustCompile(`(?ms)^\s*-+\s*$.*?^\s*-+\s*$`) + +func parseFrontmatter(context parser.Context, source []byte) (front frontmatter, err error) { + index := frontmatterRegex.FindIndex(source) + if index != nil { + front.start = index[0] + front.end = index[1] + front.values, err = meta.TryGet(context) + } + return +} + +// getString returns the first string value found for any of the given keys. +func (m frontmatter) getString(keys ...string) opt.String { + if m.values == nil { + return opt.NullString + } + + for _, key := range keys { + if val, ok := m.values[key]; ok { + if val, ok := val.(string); ok { + return opt.NewNotEmptyString(val) + } + } + } + return opt.NullString +} diff --git a/adapter/markdown/markdown_test.go b/adapter/markdown/markdown_test.go index 8f489d1..aa056ba 100644 --- a/adapter/markdown/markdown_test.go +++ b/adapter/markdown/markdown_test.go @@ -25,6 +25,22 @@ func TestParseTitle(t *testing.T) { test("# Heading 1\n## Heading 1.a\n# Heading 2", "Heading 1") test("## Small Heading\n# Bigger Heading", "Bigger Heading") test("# A **title** with [formatting](http://stripped)", "A title with formatting") + + // From a YAML frontmatter + test(`--- +Title: A title +Tags: + - tag1 + - tag2 +--- + +# Heading +`, "A title") + test(`--- +title: lowercase key +--- +Paragraph +`, "lowercase key") } func TestParseBody(t *testing.T) { @@ -63,6 +79,12 @@ Paragraph: * item1 * item2`, ) + test(`--- +title: A title +--- + +Paragraph +`, "Paragraph") } func TestParseLead(t *testing.T) { diff --git a/go.mod b/go.mod index 162b888..b6124ea 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/tebeka/strftime v0.1.5 // indirect github.com/tj/go-naturaldate v1.3.0 github.com/yuin/goldmark v1.3.1 + github.com/yuin/goldmark-meta v1.0.0 golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78 // indirect gopkg.in/djherbis/times.v1 v1.2.0 gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 77a6865..f6bccb4 100644 --- a/go.sum +++ b/go.sum @@ -165,8 +165,11 @@ github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160/go.mod h1:mZ9/Rh9oLWpLLD github.com/tj/go-naturaldate v1.3.0 h1:OgJIPkR/Jk4bFMBLbxZ8w+QUxwjqSvzd9x+yXocY4RI= github.com/tj/go-naturaldate v1.3.0/go.mod h1:rpUbjivDKiS1BlfMGc2qUKNZ/yxgthOfmytQs8d8hKk= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.1 h1:eVwehsLsZlCJCwXyGLgg+Q4iFWE/eTIMG0e8waCmm/I= github.com/yuin/goldmark v1.3.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark-meta v1.0.0 h1:ScsatUIT2gFS6azqzLGUjgOnELsBOxMXerM3ogdJhAM= +github.com/yuin/goldmark-meta v1.0.0/go.mod h1:zsNNOrZ4nLuyHAJeLQEZcQat8dm70SmB2kHbls092Gc= github.com/zclconf/go-cty v1.2.0 h1:sPHsy7ADcIZQP3vILvTjrh74ZA175TFP5vqiNK1UmlI= github.com/zclconf/go-cty v1.2.0/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= @@ -238,5 +241,6 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=