summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBrian Picciano <mediocregopher@gmail.com>2023-01-19 16:02:27 +0100
committerBrian Picciano <mediocregopher@gmail.com>2023-01-19 16:02:27 +0100
commitc23030733fe4cba578b14ad2c1d1292891202562 (patch)
tree05e200767b1a99e686dfcd18455e08fa977ebb56
parente7b5b55f6718b25a437a891a06a26c21384b6818 (diff)
Add support for gemtext posts
-rw-r--r--src/cmd/load-test-data/test-data.yml84
-rw-r--r--src/gmi/gemtext.go73
-rw-r--r--src/gmi/gemtext_test.go51
-rw-r--r--src/gmi/gmi.go2
-rw-r--r--src/http/posts.go94
-rw-r--r--src/http/tpl.go8
-rw-r--r--src/http/tpl/image.html5
-rw-r--r--src/http/tpl/post-edit.html25
8 files changed, 316 insertions, 26 deletions
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
// `</script>` 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 @@
<div style="text-align: center;">
<a href="{{ AssetURL .ID }}" target="_blank">
- <img src="{{ AssetURL .ID }}{{ if .Resizable }}?w=800{{ end }}" />
+ <img
+ src="{{ AssetURL .ID }}{{ if .Resizable }}?w=800{{ end }}"
+ alt="{{ .Descr }}"
+ />
</a>
</div>
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 @@
<input
name="id"
type="text"
+ required
placeholder="e.g. how-to-fly-a-kite"
value="{{ .Payload.Post.ID }}" />
{{ else if .Payload.IsDraft }}
@@ -43,6 +44,7 @@
<input
name="tags"
type="text"
+ required
value="{{- range $i, $tag := .Payload.Post.Tags -}}
{{- if ne $i 0 }} {{ end }}{{ $tag -}}
{{- end -}}
@@ -75,6 +77,7 @@
<input
name="title"
type="text"
+ required
value="{{ .Payload.Post.Title }}" />
</td>
</tr>
@@ -89,11 +92,33 @@
</td>
</tr>
+ <tr>
+ <td>Format</td>
+ <td>
+ <select name="format" required>
+ <option value=""></option>
+
+ {{ $format := .Payload.Post.Format }}
+ {{ range .Payload.Formats -}}
+ <option
+ {{- if eq . $format }}
+ selected
+ {{- end }}
+ value="{{ . }}" >
+ {{ . }}
+ </option>
+ {{- end }}
+
+ </select>
+ </td>
+ </tr>
+
</table>
<p>
<textarea
name="body"
+ required
placeholder="Post body"
style="width:100%;height: 75vh;"
>