pico

created pr with pico/gemtext.1 on 2026-10-26T03:36:18Z · by c8ef7d19
added pico/gemtext.2 on 2026-11-06T17:46:33Z · by c8ef7d19
1: 7aa079f ! 1: ea5719c feat(prose): gemtext support
cmds
checkout latest patchset:
ssh pr.pico.sh pull pico:gemtext | git am -3
checkout specific patchset revision:
ssh pr.pico.sh pull pico:gemtext [rev] | git am -3
add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh pico:gemtext
add comment to patch request:
ssh pr.pico.sh comment pico:gemtext "lgtm!"

Patchset pico/gemtext.2 on 2026-11-06T17:46:33Z · commit ea5719c

feat(prose): gemtext support
Eric Bower 2026-10-26T01:48:54Z
We now support gemini `.gmi` files for the prose blog. This does not enable
prose to render a blog in the gemini protocol, rather, it supports uploading
blog posts using the gemtext specification.

In order to support blog post metadata, we have extended the gemtext parser with
`=: key value` line types.

```
=: title hello world
=: date 2026-10-25
=: tags one two three
=: draft true

Hello world!
=> https://pico.sh pico's docs
> cya!
```
Semantic diff summary
20 added, 11 modified, 0 signature changed, 0 removed across 10 analyzed files (1 file skipped: unsupported file type)
+1 -1 go.mod #
......@@ -58,6 +58,7 @@ require (
5858 go.abhg.dev/goldmark/toc v0.12.0
5959 go.rockorager.dev/vaxis v0.17.1
6060 golang.org/x/crypto v0.57.0
61+ golang.org/x/net v0.59.0
6162 gopkg.in/yaml.v2 v2.4.0
6263 modernc.org/sqlite v1.60.1
6364 )
......@@ -162,7 +163,6 @@ require (
162163 go.yaml.in/yaml/v3 v3.0.5 // indirect
163164 golang.org/x/image v0.46.0 // indirect
164165 golang.org/x/mod v0.41.0 // indirect
165- golang.org/x/net v0.59.0 // indirect
166166 golang.org/x/sync v0.23.0 // indirect
167167 golang.org/x/sys v0.48.0 // indirect
168168 golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518 // indirect
+1 -0 pkg/apps/auth/api.go #
......@@ -761,6 +761,7 @@ func deserializeCaddyAccessLog(dbpool db.DB, access *AccessLog) (*db.AnalyticsVi
761761 cleanPath = strings.TrimSuffix(cleanPath, ".html")
762762 cleanPath = strings.TrimSuffix(cleanPath, ".md")
763763 cleanPath = strings.TrimSuffix(cleanPath, ".lxt")
764+ cleanPath = strings.TrimSuffix(cleanPath, ".gmi")
764765 cleanPath = strings.TrimPrefix(cleanPath, "raw/")
765766
766767 if cleanPath != "" {
+38 -3 pkg/apps/prose/api.go #
......@@ -443,6 +443,16 @@ func (web *webRouter) postHandler(w http.ResponseWriter, r *http.Request) {
443443 unlisted = true
444444 }
445445 contents = template.HTML(parsedText.Html)
446+ case ".gmi":
447+ parsedText, err := parseGemText(post.Text)
448+ if err != nil {
449+ logger.Error("could not parse gmi text", "err", err.Error())
450+ }
451+ tags = parsedText.Tags
452+ if post.Hidden || post.PublishAt.After(time.Now()) {
453+ unlisted = true
454+ }
455+ contents = template.HTML(parsedText.Html)
446456 }
447457
448458 data = PostPageData{
......@@ -711,7 +721,20 @@ func (web *webRouter) rssBlogHandler(w http.ResponseWriter, r *http.Request) {
711721 continue
712722 }
713723 content = tpl.String()
714-
724+ case ".gmi":
725+ parsedText, err := parseGemText(post.Text)
726+ if err != nil {
727+ logger.Error("parse post gmi text", "err", err.Error())
728+ }
729+ var tpl bytes.Buffer
730+ data := &PostPageData{
731+ Contents: template.HTML(parsedText.Html),
732+ }
733+ if err := ts.Execute(&tpl, data); err != nil {
734+ logger.Error("gmi template", "err", err)
735+ continue
736+ }
737+ content = tpl.String()
715738 }
716739
717740 realUrl := curl.FullPostURL(post.Username, post.Slug)
......@@ -809,7 +832,19 @@ func (web *webRouter) rssHandler(w http.ResponseWriter, r *http.Request) {
809832 continue
810833 }
811834 content = tpl.String()
812-
835+ case ".gmi":
836+ parsedText, err := parseGemText(post.Text)
837+ if err != nil {
838+ logger.Error(err.Error())
839+ }
840+ var tpl bytes.Buffer
841+ data := &PostPageData{
842+ Contents: template.HTML(parsedText.Html),
843+ }
844+ if err := ts.Execute(&tpl, data); err != nil {
845+ continue
846+ }
847+ content = tpl.String()
813848 }
814849
815850 realUrl := curl.FullPostURL(post.Username, post.Slug)
......@@ -950,7 +985,7 @@ func (web *webRouter) pathRoute(w http.ResponseWriter, r *http.Request) {
950985 web.postRawHandler(w, r)
951986 return
952987 }
953- for _, ext := range []string{".md", ".lxt"} {
988+ for _, ext := range []string{".md", ".lxt", ".gmi"} {
954989 if slug, ok := strings.CutSuffix(path, ext); ok && slug != "" {
955990 r.SetPathValue("slug", slug)
956991 web.postRawHandler(w, r)
+1 -0 pkg/apps/prose/api_test.go #
......@@ -49,6 +49,7 @@ func TestRoutes(t *testing.T) {
4949 {url: site + "/hello.html", status: http.StatusOK, body: "<html"},
5050 {url: site + "/hello.md", status: http.StatusOK, body: "# hello"},
5151 {url: site + "/hello.lxt", status: http.StatusOK, body: "# hello"},
52+ {url: site + "/hello.gmi", status: http.StatusOK, body: "# hello"},
5253 {url: site + "/raw/hello", status: http.StatusOK, body: "# hello"},
5354 {url: site + "/raw", status: http.StatusOK, body: "<html"},
5455 {url: site + "/raw.md", status: http.StatusOK, body: "# raw"},
+1 -0 pkg/apps/prose/config.go #
......@@ -51,6 +51,7 @@ func NewConfig(service string) *Config {
5151 AllowedExt: []string{
5252 ".md",
5353 ".lxt",
54+ ".gmi",
5455 ".jpg",
5556 ".jpeg",
5657 ".png",
+122 -0 pkg/apps/prose/gmi.go #
......@@ -0,0 +1,122 @@
1+package prose
2+
3+import (
4+ "fmt"
5+ "html"
6+ "io"
7+ "strings"
8+ "time"
9+
10+ "github.com/araddon/dateparse"
11+)
12+
13+type GemText struct {
14+ Html string
15+ PublishAt *time.Time
16+ Tags []string
17+ Hidden bool
18+ Title string
19+ Description string
20+}
21+
22+func parseGemText(text string) (*GemText, error) {
23+ rdr := strings.NewReader(text)
24+ var out strings.Builder
25+ hw := HTMLWriter{
26+ out: &out,
27+ }
28+ meta, err := parseGemLines(rdr, hw.Handle)
29+ if err != nil {
30+ return nil, err
31+ }
32+ hw.Finish()
33+ gem := &GemText{
34+ Html: out.String(),
35+ }
36+
37+ for k, v := range meta {
38+ switch k {
39+ case "date":
40+ nextDate, err := dateparse.ParseStrict(v)
41+ if err != nil {
42+ return gem, err
43+ }
44+ gem.PublishAt = &nextDate
45+ case "tags":
46+ gem.Tags = strings.Split(v, " ")
47+ case "draft":
48+ if v == "true" {
49+ gem.Hidden = true
50+ } else {
51+ gem.Hidden = false
52+ }
53+ case "title":
54+ gem.Title = v
55+ case "description":
56+ gem.Description = v
57+ }
58+ }
59+
60+ return gem, nil
61+}
62+
63+type HTMLWriter struct {
64+ out io.Writer
65+ pre bool
66+ list bool
67+}
68+
69+func (h *HTMLWriter) Handle(line Line) {
70+ if _, ok := line.(LineListItem); ok {
71+ if !h.list {
72+ h.list = true
73+ _, _ = fmt.Fprint(h.out, "<ul>\n")
74+ }
75+ } else if h.list {
76+ h.list = false
77+ _, _ = fmt.Fprint(h.out, "</ul>\n")
78+ }
79+ switch line := line.(type) {
80+ case LineLink:
81+ url := html.EscapeString(line.URL)
82+ name := html.EscapeString(line.Name)
83+ if name == "" {
84+ name = url
85+ }
86+ _, _ = fmt.Fprintf(h.out, "<p><a href='%s'>%s</a></p>\n", url, name)
87+ case LinePreformattingToggle:
88+ h.pre = !h.pre
89+ if h.pre {
90+ _, _ = fmt.Fprint(h.out, "<pre>\n")
91+ } else {
92+ _, _ = fmt.Fprint(h.out, "</pre>\n")
93+ }
94+ case LinePreformattedText:
95+ _, _ = fmt.Fprintf(h.out, "%s\n", html.EscapeString(string(line)))
96+ case LineHeading1:
97+ _, _ = fmt.Fprintf(h.out, "<h1>%s</h1>\n", html.EscapeString(string(line)))
98+ case LineHeading2:
99+ _, _ = fmt.Fprintf(h.out, "<h2>%s</h2>\n", html.EscapeString(string(line)))
100+ case LineHeading3:
101+ _, _ = fmt.Fprintf(h.out, "<h3>%s</h3>\n", html.EscapeString(string(line)))
102+ case LineListItem:
103+ _, _ = fmt.Fprintf(h.out, "<li>%s</li>\n", html.EscapeString(string(line)))
104+ case LineQuote:
105+ _, _ = fmt.Fprintf(h.out, "<blockquote>%s</blockquote>\n", html.EscapeString(string(line)))
106+ case LineText:
107+ if line == "" {
108+ // do nothing
109+ } else {
110+ _, _ = fmt.Fprintf(h.out, "<p>%s</p>\n", html.EscapeString(string(line)))
111+ }
112+ }
113+}
114+
115+func (h *HTMLWriter) Finish() {
116+ if h.pre {
117+ _, _ = fmt.Fprint(h.out, "</pre>\n")
118+ }
119+ if h.list {
120+ _, _ = fmt.Fprint(h.out, "</ul>\n")
121+ }
122+}
+173 -0 pkg/apps/prose/gmitext.go #
......@@ -0,0 +1,173 @@
1+package prose
2+
3+import (
4+ "bufio"
5+ "fmt"
6+ "io"
7+ "strings"
8+)
9+
10+// Line represents a line of a Gemini text response.
11+type Line interface {
12+ // String formats the line for use in a Gemini text response.
13+ String() string
14+ line() // private function to prevent other packages from implementing Line
15+}
16+
17+// LineLink is a link line.
18+type LineLink struct {
19+ URL string
20+ Name string
21+}
22+
23+// LinePreformattingToggle is a preformatting toggle line.
24+type LinePreformattingToggle string
25+
26+// LinePreformattedText is a preformatted text line.
27+type LinePreformattedText string
28+
29+// LineHeading1 is a first-level heading line.
30+type LineHeading1 string
31+
32+// LineHeading2 is a second-level heading line.
33+type LineHeading2 string
34+
35+// LineHeading3 is a third-level heading line.
36+type LineHeading3 string
37+
38+// LineListItem is an unordered list item line.
39+type LineListItem string
40+
41+// LineQuote is a quote line.
42+type LineQuote string
43+
44+// LineText is a text line.
45+type LineText string
46+
47+func (l LineLink) String() string {
48+ if l.Name != "" {
49+ return fmt.Sprintf("=> %s %s", l.URL, l.Name)
50+ }
51+ return fmt.Sprintf("=> %s", l.URL)
52+}
53+func (l LinePreformattingToggle) String() string {
54+ return fmt.Sprintf("```%s", string(l))
55+}
56+func (l LinePreformattedText) String() string {
57+ return string(l)
58+}
59+func (l LineHeading1) String() string {
60+ return fmt.Sprintf("# %s", string(l))
61+}
62+func (l LineHeading2) String() string {
63+ return fmt.Sprintf("## %s", string(l))
64+}
65+func (l LineHeading3) String() string {
66+ return fmt.Sprintf("### %s", string(l))
67+}
68+func (l LineListItem) String() string {
69+ return fmt.Sprintf("* %s", string(l))
70+}
71+func (l LineQuote) String() string {
72+ return fmt.Sprintf("> %s", string(l))
73+}
74+func (l LineText) String() string {
75+ return string(l)
76+}
77+
78+func (l LineLink) line() {}
79+func (l LinePreformattingToggle) line() {}
80+func (l LinePreformattedText) line() {}
81+func (l LineHeading1) line() {}
82+func (l LineHeading2) line() {}
83+func (l LineHeading3) line() {}
84+func (l LineListItem) line() {}
85+func (l LineQuote) line() {}
86+func (l LineText) line() {}
87+
88+// Text represents a Gemini text response.
89+type Text []Line
90+
91+// // ParseText parses Gemini text from the provided io.Reader.
92+// func ParseGemText(r io.Reader) (Text, error) {
93+// var t Text
94+// err := parseGemLines(r, func(line Line) {
95+// t = append(t, line)
96+// })
97+// return t, err
98+// }
99+
100+// ParseLines parses Gemini text from the provided io.Reader.
101+// It calls handler with each line that it parses.
102+func parseGemLines(r io.Reader, handler func(Line)) (map[string]string, error) {
103+ const spacetab = " \t"
104+ var pre bool
105+ meta := map[string]string{}
106+ scanner := bufio.NewScanner(r)
107+ for scanner.Scan() {
108+ var line Line
109+ text := scanner.Text()
110+ if strings.HasPrefix(text, "```") {
111+ pre = !pre
112+ text = text[3:]
113+ line = LinePreformattingToggle(text)
114+ } else if pre {
115+ line = LinePreformattedText(text)
116+ } else if strings.HasPrefix(text, "=>") {
117+ text = text[2:]
118+ text = strings.TrimLeft(text, spacetab)
119+ split := strings.IndexAny(text, spacetab)
120+ if split == -1 {
121+ // text is a URL
122+ line = LineLink{URL: text}
123+ } else {
124+ url := text[:split]
125+ name := text[split:]
126+ name = strings.TrimLeft(name, spacetab)
127+ line = LineLink{url, name}
128+ }
129+ } else if strings.HasPrefix(text, "* ") {
130+ text = text[2:]
131+ text = strings.TrimLeft(text, spacetab)
132+ line = LineListItem(text)
133+ } else if strings.HasPrefix(text, "###") {
134+ text = text[3:]
135+ text = strings.TrimLeft(text, spacetab)
136+ line = LineHeading3(text)
137+ } else if strings.HasPrefix(text, "##") {
138+ text = text[2:]
139+ text = strings.TrimLeft(text, spacetab)
140+ line = LineHeading2(text)
141+ } else if strings.HasPrefix(text, "#") {
142+ text = text[1:]
143+ text = strings.TrimLeft(text, spacetab)
144+ line = LineHeading1(text)
145+ } else if strings.HasPrefix(text, ">") {
146+ text = text[1:]
147+ text = strings.TrimLeft(text, spacetab)
148+ line = LineQuote(text)
149+ } else if strings.HasPrefix(text, "=: ") { // extension to gemtext spec to support metadata
150+ text = text[3:]
151+ kv := strings.SplitN(text, " ", 2)
152+ if len(kv) <= 1 {
153+ continue
154+ }
155+ meta[kv[0]] = kv[1]
156+ } else {
157+ line = LineText(text)
158+ }
159+ handler(line)
160+ }
161+
162+ return meta, scanner.Err()
163+}
164+
165+// String writes the Gemini text response to a string and returns it.
166+func (t Text) String() string {
167+ var b strings.Builder
168+ for _, l := range t {
169+ b.WriteString(l.String())
170+ b.WriteByte('\n')
171+ }
172+ return b.String()
173+}
+5 -0 pkg/apps/prose/post_uploader.go #
......@@ -6,6 +6,7 @@ import (
66 "path/filepath"
77 "slices"
88 "strings"
9+ "time"
910
1011 "github.com/picosh/pico/pkg/db"
1112 "github.com/picosh/pico/pkg/postfs"
......@@ -171,6 +172,10 @@ func (h *postUploader) setMeta(post *db.Post) ([]string, error) {
171172 post.PublishAt = parsedText.PublishAt
172173 }
173174 post.Hidden = parsedText.Hidden
175+ case ".gmi":
176+ post.Title = shared.ToLower(post.Slug)
177+ now := time.Now()
178+ post.PublishAt = &now
174179 case ".md":
175180 parsedText, err := shared.ParseText(post.Text)
176181 if err != nil {
+1 -0 pkg/apps/prose/router_test.go #
......@@ -38,6 +38,7 @@ func TestRouterSharedHandler(t *testing.T) {
3838 router := newFileRouter("prose", map[string]ReadWriteHandler{
3939 ".md": posts,
4040 ".lxt": posts,
41+ ".gmi": posts,
4142 ".txt": &listHandler{names: []string{"a.md"}},
4243 "fallback": &listHandler{names: []string{"x.png"}},
4344 })
+1 -0 pkg/apps/prose/uploader.go #
......@@ -53,6 +53,7 @@ func NewFileHandler(cfg *Config, dbpool DB, st storage.StorageServe) *UploadHand
5353 ".txt": posts,
5454 ".css": posts,
5555 ".lxt": posts,
56+ ".gmi": posts,
5657 "fallback": NewUploadImgHandler(dbpool, cfg, st),
5758 })
5859 }
+11 -0 pkg/shared/util.go #
......@@ -49,6 +49,17 @@ func ToUpper(str string) string {
4949 return string(r)
5050 }
5151
52+func ToLower(str string) string {
53+ pre := fnameRe.ReplaceAllString(str, " ")
54+
55+ r := []rune(pre)
56+ if len(r) > 0 {
57+ r[0] = unicode.ToLower(r[0])
58+ }
59+
60+ return string(r)
61+}
62+
5263 func SanitizeFileExt(fname string) string {
5364 return strings.TrimSuffix(fname, filepath.Ext(fname))
5465 }
Back to top