Compare commits

...

2 Commits

Author SHA1 Message Date
Brian Picciano
c23030733f Add support for gemtext posts 2023-01-19 16:02:27 +01:00
Brian Picciano
e7b5b55f67 Add format column to post tables 2023-01-18 20:15:12 +01:00
14 changed files with 388 additions and 41 deletions

View File

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

View File

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

View File

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

View File

@ -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;"
>

View File

@ -49,22 +49,24 @@ func (s *draftStore) Set(post Post) error {
_, err = s.db.db.Exec(
`INSERT INTO post_drafts (
id, title, description, tags, series, body
id, title, description, tags, series, body, format
)
VALUES
(?, ?, ?, ?, ?, ?)
(?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
title=excluded.title,
description=excluded.description,
tags=excluded.tags,
series=excluded.series,
body=excluded.body`,
body=excluded.body,
format=excluded.format`,
post.ID,
post.Title,
&sql.NullString{String: post.Description, Valid: len(post.Description) > 0},
&sql.NullString{String: string(tagsJSON), Valid: len(post.Tags) > 0},
&sql.NullString{String: post.Series, Valid: post.Series != ""},
post.Body,
post.Format,
)
if err != nil {
@ -86,7 +88,7 @@ func (s *draftStore) get(
query := `
SELECT
p.id, p.title, p.description, p.tags, p.series, p.body
p.id, p.title, p.description, p.tags, p.series, p.body, p.format
FROM post_drafts p
` + where + `
ORDER BY p.id ASC`
@ -118,7 +120,7 @@ func (s *draftStore) get(
err := rows.Scan(
&post.ID, &post.Title, &description, &tags, &series,
&post.Body,
&post.Body, &post.Format,
)
if err != nil {

View File

@ -72,6 +72,7 @@ func TestDraftStore(t *testing.T) {
post.Series = "whatever"
post.Body = "anything"
post.Tags = []string{"bar", "baz"}
post.Format = FormatGemtext
err = h.store.Set(post)
assert.NoError(t, err)

44
src/post/format.go Normal file
View File

@ -0,0 +1,44 @@
package post
import "errors"
// ErrFormatStringMalformed indicates that a string could not be converted to a
// Format.
var ErrFormatStringMalformed = errors.New("format string malformed")
// Format describes the format of the body of a Post.
type Format string
// Enumeration of possible formats.
const (
FormatMarkdown Format = "md"
FormatGemtext Format = "gmi"
)
// Formats slice of all possible Formats.
var Formats = []Format{
FormatMarkdown,
FormatGemtext,
}
var strsToFormats = func() map[string]Format {
m := map[string]Format{}
for _, f := range Formats {
m[string(f)] = f
}
return m
}()
// FormatFromString parses a string into a Format, or returns
// ErrFormatStringMalformed.
func FormatFromString(str string) (Format, error) {
if f, ok := strsToFormats[str]; ok {
return f, nil
}
return "", ErrFormatStringMalformed
}

View File

@ -34,6 +34,7 @@ type Post struct {
Tags []string // only alphanumeric supported
Series string
Body string
Format Format
}
// StoredPost is a Post which has been stored in a Store, and has been given
@ -103,22 +104,24 @@ func (s *store) Set(post Post, now time.Time) (bool, error) {
_, err := tx.Exec(
`INSERT INTO posts (
id, title, description, series, published_at, body
id, title, description, series, published_at, body, format
)
VALUES
(?, ?, ?, ?, ?, ?)
(?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
title=excluded.title,
description=excluded.description,
series=excluded.series,
last_updated_at=?,
body=excluded.body`,
body=excluded.body,
format=excluded.format`,
post.ID,
post.Title,
&sql.NullString{String: post.Description, Valid: len(post.Description) > 0},
&sql.NullString{String: post.Series, Valid: post.Series != ""},
nowSQL,
post.Body,
post.Format,
nowSQL,
)
@ -174,8 +177,7 @@ func (s *store) get(
query := `
SELECT
p.id, p.title, p.description, p.series, GROUP_CONCAT(pt.tag),
p.published_at, p.last_updated_at,
p.body
p.published_at, p.last_updated_at, p.body, p.format
FROM posts p
LEFT JOIN post_tags pt ON (p.id = pt.post_id)
` + where + `
@ -208,8 +210,7 @@ func (s *store) get(
err := rows.Scan(
&post.ID, &post.Title, &description, &series, &tag,
&publishedAt, &lastUpdatedAt,
&post.Body,
&publishedAt, &lastUpdatedAt, &post.Body, &post.Format,
)
if err != nil {

View File

@ -37,9 +37,10 @@ func TestNewID(t *testing.T) {
func testPost(i int) Post {
istr := strconv.Itoa(i)
return Post{
ID: istr,
Title: istr,
Body: istr,
ID: istr,
Title: istr,
Body: istr,
Format: FormatMarkdown,
}
}
@ -108,7 +109,7 @@ func TestStore(t *testing.T) {
post.Tags = []string{"foo", "bar"}
first, err := h.store.Set(post, now)
assert.NoError(t, err)
assert.NoError(t, err, "post:%+v", post)
assert.True(t, first)
gotPost, err := h.store.GetByID(post.ID)
@ -130,6 +131,7 @@ func TestStore(t *testing.T) {
post.Series = "whatever"
post.Body = "anything"
post.Tags = []string{"bar", "baz"}
post.Format = FormatGemtext
first, err = h.store.Set(post, newNow)
assert.NoError(t, err)

View File

@ -66,6 +66,13 @@ var migrations = &migrate.MemoryMigrationSource{Migrations: []*migrate.Migration
`ALTER TABLE posts DROP COLUMN description_old`,
},
},
{
Id: "4",
Up: []string{
`ALTER TABLE post_drafts ADD COLUMN format TEXT DEFAULT 'md'`,
`ALTER TABLE posts ADD COLUMN format TEXT DEFAULT 'md'`,
},
},
}}
// SQLDB is a sqlite3 database which can be used by storage interfaces within