From c23030733fe4cba578b14ad2c1d1292891202562 Mon Sep 17 00:00:00 2001 From: Brian Picciano Date: Thu, 19 Jan 2023 16:02:27 +0100 Subject: Add support for gemtext posts --- src/cmd/load-test-data/test-data.yml | 84 ++++++++++++++++++++++++++++++-- src/gmi/gemtext.go | 73 ++++++++++++++++++++++++++++ src/gmi/gemtext_test.go | 51 +++++++++++++++++++ src/gmi/gmi.go | 2 + src/http/posts.go | 94 ++++++++++++++++++++++++++++-------- src/http/tpl.go | 8 ++- src/http/tpl/image.html | 5 +- src/http/tpl/post-edit.html | 25 ++++++++++ 8 files changed, 316 insertions(+), 26 deletions(-) create mode 100644 src/gmi/gemtext.go create mode 100644 src/gmi/gemtext_test.go create mode 100644 src/gmi/gmi.go diff --git a/src/cmd/load-test-data/test-data.yml b/src/cmd/load-test-data/test-data.yml index 01030b7..a3fff9d 100644 --- a/src/cmd/load-test-data/test-data.yml +++ b/src/cmd/load-test-data/test-data.yml @@ -8,6 +8,7 @@ published_posts: tags: - foo series: testing + format: md body: | This here's a test post containing various markdown elements in its body. @@ -59,17 +60,83 @@ published_posts: Here's a real picture of cyberspace. - ![not a sound stage]({{ AssetURL "galaxy.jpg" }}) + {{ Image "galaxy.jpg" }} This has been a great post. - - id: empty-test - title: Empty Test + - id: gemtext-test + title: Gemtext Test + description: A little post containing different kinds of gemtext elements. + tags: + - foo + series: testing + format: gmi + body: | + + This here's a test post containing various markdown elements in its body. It's useful for making sure that posts will look good (generally). + + ## Let's Begin + + There's various things worth testing. Like lists: + + * Foo + * Bar + * Baz + + So many! + + ### A Subsection + + And it only gets crazier from here! + + Check out this code block. + + ``` + // It's like actually being in the matrix + for !dead { + if awake { + work() + } else { + continue + } + } + ``` + + Edgy. + + #### Side-note + + Did you know that the terms "cyberspace" and "matrix" are attributable to a book from 1984 called _Neuromancer_? + + > The 1999 cyberpunk science fiction film The Matrix particularly draws from Neuromancer both eponym and usage of the term "matrix". + > - Wikipedia + + Here's a real picture of cyberspace. + + {{ Image "galaxy.jpg" "Definitely not a sound stage" }} + + This has been a great post. + + => / Here's a link outa here! + + - id: empty-markdown-test + title: Empty Markdown Test description: tags: - foo - bar series: testing + format: md + body: "" + + - id: empty-gemtext-test + title: Empty Gemtext Test + description: + tags: + - foo + - bar + series: testing + format: gmi body: "" - id: little-markdown-test @@ -78,6 +145,17 @@ published_posts: tags: - bar series: testing + format: md + body: | + This page is almost empty. + + - id: little-gemtext-test + title: Little Gemtext Test + description: A post with almost no content. + tags: + - bar + series: testing + format: gmi body: | This page is almost empty. diff --git a/src/gmi/gemtext.go b/src/gmi/gemtext.go new file mode 100644 index 0000000..a1136bd --- /dev/null +++ b/src/gmi/gemtext.go @@ -0,0 +1,73 @@ +package gmi + +import ( + "bufio" + "errors" + "fmt" + "io" + "log" + "path" + "regexp" + "strings" +) + +func hasImgExt(p string) bool { + switch path.Ext(strings.ToLower(p)) { + case ".jpg", ".jpeg", ".png", ".gif", ".svg": + return true + default: + return false + } +} + +// matches `=> dstURL [optional description]` +var linkRegexp = regexp.MustCompile(`^=>\s+(\S+)\s*(.*?)\s*$`) + +// GemtextToMarkdown reads a gemtext formatted body from the Reader and writes +// the markdown version of that body to the Writer. +func GemtextToMarkdown(dst io.Writer, src io.Reader) error { + + bufSrc := bufio.NewReader(src) + + for { + + line, err := bufSrc.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("reading: %w", err) + } + + last := err == io.EOF + + if match := linkRegexp.FindStringSubmatch(line); len(match) > 0 { + + isImg := hasImgExt(match[1]) + + descr := match[2] + + if descr != "" { + // ok + } else if isImg { + descr = "Image" + } else { + descr = "Link" + } + + log.Printf("descr:%q", descr) + + line = fmt.Sprintf("[%s](%s)\n", descr, match[1]) + + if isImg { + line = "!" + line + } + } + + if _, err := dst.Write([]byte(line)); err != nil { + return fmt.Errorf("writing: %w", err) + } + + if last { + return nil + } + } + +} diff --git a/src/gmi/gemtext_test.go b/src/gmi/gemtext_test.go new file mode 100644 index 0000000..23cb97f --- /dev/null +++ b/src/gmi/gemtext_test.go @@ -0,0 +1,51 @@ +package gmi + +import ( + "bytes" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGemtextToMarkdown(t *testing.T) { + + tests := []struct { + in, exp string + }{ + { + in: "", + exp: "", + }, + { + in: "=> foo", + exp: "[Link](foo)\n", + }, + { + in: "what\n=> foo\n=> bar", + exp: "what\n[Link](foo)\n[Link](bar)\n", + }, + { + in: "=> foo description is here ", + exp: "[description is here](foo)\n", + }, + { + in: "=> img.png", + exp: "![Image](img.png)\n", + }, + { + in: "=> img.png description is here ", + exp: "![description is here](img.png)\n", + }, + } + + for i, test := range tests { + t.Run(strconv.Itoa(i), func(t *testing.T) { + + got := new(bytes.Buffer) + err := GemtextToMarkdown(got, bytes.NewBufferString(test.in)) + assert.NoError(t, err) + assert.Equal(t, test.exp, got.String()) + }) + } +} diff --git a/src/gmi/gmi.go b/src/gmi/gmi.go new file mode 100644 index 0000000..358d935 --- /dev/null +++ b/src/gmi/gmi.go @@ -0,0 +1,2 @@ +// Package gmi contains utilities for working with gemini and gemtext +package gmi diff --git a/src/http/posts.go b/src/http/posts.go index 0f8924a..eff0eaa 100644 --- a/src/http/posts.go +++ b/src/http/posts.go @@ -15,37 +15,68 @@ import ( "github.com/gomarkdown/markdown" "github.com/gomarkdown/markdown/html" "github.com/gomarkdown/markdown/parser" + "github.com/mediocregopher/blog.mediocregopher.com/srv/gmi" "github.com/mediocregopher/blog.mediocregopher.com/srv/http/apiutil" "github.com/mediocregopher/blog.mediocregopher.com/srv/post" "github.com/mediocregopher/mediocre-go-lib/v2/mctx" ) -func (a *api) parsePostBody(post post.Post) (*txttpl.Template, error) { +func (a *api) parsePostBody(p post.Post) (*txttpl.Template, error) { tpl := txttpl.New("root") tpl = tpl.Funcs(txttpl.FuncMap(a.tplFuncs())) tpl = txttpl.Must(tpl.New("image.html").Parse(mustReadTplFile("image.html"))) - tpl = tpl.Funcs(txttpl.FuncMap{ - "Image": func(id string) (string, error) { - - tplPayload := struct { - ID string - Resizable bool - }{ - ID: id, - Resizable: isImgResizable(id), - } - buf := new(bytes.Buffer) - if err := tpl.ExecuteTemplate(buf, "image.html", tplPayload); err != nil { - return "", err - } + if p.Format == post.FormatMarkdown { + tpl = tpl.Funcs(txttpl.FuncMap{ + "Image": func(id string) (string, error) { + + tplPayload := struct { + ID string + Descr string + Resizable bool + }{ + ID: id, + // I could use variadic args to make this work, I think + Descr: "TODO: proper alt text", + Resizable: isImgResizable(id), + } + + buf := new(bytes.Buffer) + if err := tpl.ExecuteTemplate(buf, "image.html", tplPayload); err != nil { + return "", err + } + + return buf.String(), nil + }, + }) + } - return buf.String(), nil - }, - }) + if p.Format == post.FormatGemtext { + tpl = tpl.Funcs(txttpl.FuncMap{ + "Image": func(id, descr string) (string, error) { + + tplPayload := struct { + ID string + Descr string + Resizable bool + }{ + ID: id, + Descr: descr, + Resizable: isImgResizable(id), + } + + buf := new(bytes.Buffer) + if err := tpl.ExecuteTemplate(buf, "image.html", tplPayload); err != nil { + return "", err + } + + return buf.String(), nil + }, + }) + } - tpl, err := tpl.New(post.ID + "-body.html").Parse(post.Body) + tpl, err := tpl.New(p.ID + "-body.html").Parse(p.Body) if err != nil { return nil, err @@ -73,6 +104,16 @@ func (a *api) postToPostTplPayload(storedPost post.StoredPost) (postTplPayload, return postTplPayload{}, fmt.Errorf("executing post body as template: %w", err) } + if storedPost.Format == post.FormatGemtext { + + prevBodyBuf := bodyBuf + bodyBuf = new(bytes.Buffer) + + if err := gmi.GemtextToMarkdown(bodyBuf, prevBodyBuf); err != nil { + return postTplPayload{}, fmt.Errorf("converting gemtext to markdown: %w", err) + } + } + // this helps the markdown renderer properly parse pages which end in a // `` tag... I don't know why. _, _ = bodyBuf.WriteString("\n") @@ -324,10 +365,12 @@ func (a *api) editPostHandler(isDraft bool) http.Handler { Post post.StoredPost Tags []string IsDraft bool + Formats []post.Format }{ Post: storedPost, Tags: tags, IsDraft: isDraft, + Formats: post.Formats, } executeTemplate(rw, r, tpl, tplPayload) @@ -336,12 +379,23 @@ func (a *api) editPostHandler(isDraft bool) http.Handler { func postFromPostReq(r *http.Request) (post.Post, error) { + formatStr := r.PostFormValue("format") + if formatStr == "" { + return post.Post{}, errors.New("format is required") + } + + format, err := post.FormatFromString(formatStr) + if err != nil { + return post.Post{}, fmt.Errorf("parsing format: %w", err) + } + p := post.Post{ ID: r.PostFormValue("id"), Title: r.PostFormValue("title"), Description: r.PostFormValue("description"), Tags: strings.Fields(r.PostFormValue("tags")), Series: r.PostFormValue("series"), + Format: format, } // textareas encode newlines as CRLF for historical reasons @@ -353,7 +407,7 @@ func postFromPostReq(r *http.Request) (post.Post, error) { p.Title == "" || p.Body == "" || len(p.Tags) == 0 { - return post.Post{}, errors.New("ID, Title, Tags, and Body are all required") + return post.Post{}, errors.New("id, ritle, tags, and body are all required") } return p, nil diff --git a/src/http/tpl.go b/src/http/tpl.go index a9f89d7..f49232a 100644 --- a/src/http/tpl.go +++ b/src/http/tpl.go @@ -61,6 +61,11 @@ func (a *api) manageAssetsURL(abs bool) string { return a.blogURL("assets?method=manage", abs) } +func (a *api) assetURL(id string, abs bool) string { + path := filepath.Join("assets", id) + return a.blogURL(path, false) +} + func (a *api) draftPostURL(id string, abs bool) string { path := filepath.Join("drafts", id) return a.blogURL(path, abs) @@ -96,8 +101,7 @@ func (a *api) tplFuncs() template.FuncMap { return a.postURL(id, false) }, "AssetURL": func(id string) string { - path := filepath.Join("assets", id) - return a.blogURL(path, false) + return a.assetURL(id, false) }, "DraftURL": func(id string) string { return a.draftPostURL(id, false) diff --git a/src/http/tpl/image.html b/src/http/tpl/image.html index ba9b75d..c6c19b3 100644 --- a/src/http/tpl/image.html +++ b/src/http/tpl/image.html @@ -1,5 +1,8 @@
- + {{ .Descr }}
diff --git a/src/http/tpl/post-edit.html b/src/http/tpl/post-edit.html index 4c08744..c66b60a 100644 --- a/src/http/tpl/post-edit.html +++ b/src/http/tpl/post-edit.html @@ -25,6 +25,7 @@ {{ else if .Payload.IsDraft }} @@ -43,6 +44,7 @@ @@ -89,11 +92,33 @@ + + Format + + + + +