Add support for gemtext posts
This commit is contained in:
parent
e7b5b55f67
commit
c23030733f
@ -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
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"
|
||||||
"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")))
|
||||||
tpl = tpl.Funcs(txttpl.FuncMap{
|
|
||||||
"Image": func(id string) (string, error) {
|
|
||||||
|
|
||||||
tplPayload := struct {
|
if p.Format == post.FormatMarkdown {
|
||||||
ID string
|
tpl = tpl.Funcs(txttpl.FuncMap{
|
||||||
Resizable bool
|
"Image": func(id string) (string, error) {
|
||||||
}{
|
|
||||||
ID: id,
|
|
||||||
Resizable: isImgResizable(id),
|
|
||||||
}
|
|
||||||
|
|
||||||
buf := new(bytes.Buffer)
|
tplPayload := struct {
|
||||||
if err := tpl.ExecuteTemplate(buf, "image.html", tplPayload); err != nil {
|
ID string
|
||||||
return "", err
|
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 {
|
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
|
||||||
|
@ -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)
|
||||||
|
@ -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>
|
||||||
|
@ -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;"
|
||||||
>
|
>
|
||||||
|
Loading…
Reference in New Issue
Block a user