Add support for gemtext posts
This commit is contained in:
parent
e7b5b55f67
commit
c23030733f
@ -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.
|
||||
|
||||
|
73
src/gmi/gemtext.go
Normal file
73
src/gmi/gemtext.go
Normal 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
51
src/gmi/gemtext_test.go
Normal 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
2
src/gmi/gmi.go
Normal file
@ -0,0 +1,2 @@
|
||||
// Package gmi contains utilities for working with gemini and gemtext
|
||||
package gmi
|
@ -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),
|
||||
}
|
||||
if p.Format == post.FormatMarkdown {
|
||||
tpl = tpl.Funcs(txttpl.FuncMap{
|
||||
"Image": func(id string) (string, error) {
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
if err := tpl.ExecuteTemplate(buf, "image.html", tplPayload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
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),
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
},
|
||||
})
|
||||
buf := new(bytes.Buffer)
|
||||
if err := tpl.ExecuteTemplate(buf, "image.html", tplPayload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tpl, err := tpl.New(post.ID + "-body.html").Parse(post.Body)
|
||||
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(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
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
|
@ -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;"
|
||||
>
|
||||
|
Loading…
Reference in New Issue
Block a user