Add support for gemtext posts

This commit is contained in:
Brian Picciano 2023-01-19 16:02:27 +01:00
parent e7b5b55f67
commit c23030733f
8 changed files with 315 additions and 25 deletions

View File

@ -8,6 +8,7 @@ published_posts:
tags: tags:
- foo - foo
series: testing series: testing
format: md
body: | body: |
This here's a test post containing various markdown elements in its 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. Here's a real picture of cyberspace.
![not a sound stage]({{ AssetURL "galaxy.jpg" }}) {{ Image "galaxy.jpg" }}
This has been a great post. This has been a great post.
- id: empty-test - id: gemtext-test
title: Empty 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: description:
tags: tags:
- foo - foo
- bar - bar
series: testing series: testing
format: md
body: ""
- id: empty-gemtext-test
title: Empty Gemtext Test
description:
tags:
- foo
- bar
series: testing
format: gmi
body: "" body: ""
- id: little-markdown-test - id: little-markdown-test
@ -78,6 +145,17 @@ published_posts:
tags: tags:
- bar - bar
series: testing 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: | body: |
This page is almost empty. This page is almost empty.

73
src/gmi/gemtext.go Normal file
View File

@ -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
}
}
}

51
src/gmi/gemtext_test.go Normal file
View File

@ -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())
})
}
}

2
src/gmi/gmi.go Normal file
View File

@ -0,0 +1,2 @@
// Package gmi contains utilities for working with gemini and gemtext
package gmi

View File

@ -15,24 +15,30 @@ import (
"github.com/gomarkdown/markdown" "github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html" "github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser" "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/http/apiutil"
"github.com/mediocregopher/blog.mediocregopher.com/srv/post" "github.com/mediocregopher/blog.mediocregopher.com/srv/post"
"github.com/mediocregopher/mediocre-go-lib/v2/mctx" "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 := txttpl.New("root")
tpl = tpl.Funcs(txttpl.FuncMap(a.tplFuncs())) tpl = tpl.Funcs(txttpl.FuncMap(a.tplFuncs()))
tpl = txttpl.Must(tpl.New("image.html").Parse(mustReadTplFile("image.html"))) tpl = txttpl.Must(tpl.New("image.html").Parse(mustReadTplFile("image.html")))
if p.Format == post.FormatMarkdown {
tpl = tpl.Funcs(txttpl.FuncMap{ tpl = tpl.Funcs(txttpl.FuncMap{
"Image": func(id string) (string, error) { "Image": func(id string) (string, error) {
tplPayload := struct { tplPayload := struct {
ID string ID string
Descr string
Resizable bool Resizable bool
}{ }{
ID: id, ID: id,
// I could use variadic args to make this work, I think
Descr: "TODO: proper alt text",
Resizable: isImgResizable(id), Resizable: isImgResizable(id),
} }
@ -44,8 +50,33 @@ func (a *api) parsePostBody(post post.Post) (*txttpl.Template, error) {
return buf.String(), nil return buf.String(), nil
}, },
}) })
}
tpl, err := tpl.New(post.ID + "-body.html").Parse(post.Body) 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(p.ID + "-body.html").Parse(p.Body)
if err != nil { if err != nil {
return nil, err 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) 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 // this helps the markdown renderer properly parse pages which end in a
// `</script>` tag... I don't know why. // `</script>` tag... I don't know why.
_, _ = bodyBuf.WriteString("\n") _, _ = bodyBuf.WriteString("\n")
@ -324,10 +365,12 @@ func (a *api) editPostHandler(isDraft bool) http.Handler {
Post post.StoredPost Post post.StoredPost
Tags []string Tags []string
IsDraft bool IsDraft bool
Formats []post.Format
}{ }{
Post: storedPost, Post: storedPost,
Tags: tags, Tags: tags,
IsDraft: isDraft, IsDraft: isDraft,
Formats: post.Formats,
} }
executeTemplate(rw, r, tpl, tplPayload) 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) { 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{ p := post.Post{
ID: r.PostFormValue("id"), ID: r.PostFormValue("id"),
Title: r.PostFormValue("title"), Title: r.PostFormValue("title"),
Description: r.PostFormValue("description"), Description: r.PostFormValue("description"),
Tags: strings.Fields(r.PostFormValue("tags")), Tags: strings.Fields(r.PostFormValue("tags")),
Series: r.PostFormValue("series"), Series: r.PostFormValue("series"),
Format: format,
} }
// textareas encode newlines as CRLF for historical reasons // textareas encode newlines as CRLF for historical reasons
@ -353,7 +407,7 @@ func postFromPostReq(r *http.Request) (post.Post, error) {
p.Title == "" || p.Title == "" ||
p.Body == "" || p.Body == "" ||
len(p.Tags) == 0 { 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 return p, nil

View File

@ -61,6 +61,11 @@ func (a *api) manageAssetsURL(abs bool) string {
return a.blogURL("assets?method=manage", abs) 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 { func (a *api) draftPostURL(id string, abs bool) string {
path := filepath.Join("drafts", id) path := filepath.Join("drafts", id)
return a.blogURL(path, abs) return a.blogURL(path, abs)
@ -96,8 +101,7 @@ func (a *api) tplFuncs() template.FuncMap {
return a.postURL(id, false) return a.postURL(id, false)
}, },
"AssetURL": func(id string) string { "AssetURL": func(id string) string {
path := filepath.Join("assets", id) return a.assetURL(id, false)
return a.blogURL(path, false)
}, },
"DraftURL": func(id string) string { "DraftURL": func(id string) string {
return a.draftPostURL(id, false) return a.draftPostURL(id, false)

View File

@ -1,5 +1,8 @@
<div style="text-align: center;"> <div style="text-align: center;">
<a href="{{ AssetURL .ID }}" target="_blank"> <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> </a>
</div> </div>

View File

@ -25,6 +25,7 @@
<input <input
name="id" name="id"
type="text" type="text"
required
placeholder="e.g. how-to-fly-a-kite" placeholder="e.g. how-to-fly-a-kite"
value="{{ .Payload.Post.ID }}" /> value="{{ .Payload.Post.ID }}" />
{{ else if .Payload.IsDraft }} {{ else if .Payload.IsDraft }}
@ -43,6 +44,7 @@
<input <input
name="tags" name="tags"
type="text" type="text"
required
value="{{- range $i, $tag := .Payload.Post.Tags -}} value="{{- range $i, $tag := .Payload.Post.Tags -}}
{{- if ne $i 0 }} {{ end }}{{ $tag -}} {{- if ne $i 0 }} {{ end }}{{ $tag -}}
{{- end -}} {{- end -}}
@ -75,6 +77,7 @@
<input <input
name="title" name="title"
type="text" type="text"
required
value="{{ .Payload.Post.Title }}" /> value="{{ .Payload.Post.Title }}" />
</td> </td>
</tr> </tr>
@ -89,11 +92,33 @@
</td> </td>
</tr> </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> </table>
<p> <p>
<textarea <textarea
name="body" name="body"
required
placeholder="Post body" placeholder="Post body"
style="width:100%;height: 75vh;" style="width:100%;height: 75vh;"
> >