pico
created pr with
pico/gemtext.1
added pico/gemtext.2
1: 7aa079f ! 1: ea5719c feat(prose): gemtext support
cmds
checkout latest patchset:
ssh pr.pico.sh pull pico:gemtext | git am -3checkout specific patchset revision:
ssh pr.pico.sh pull pico:gemtext [rev] | git am -3add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh pico:gemtextadd comment to patch request:
ssh pr.pico.sh comment pico:gemtext "lgtm!"
Patchset
pico/gemtext.2
feat(prose): gemtext support
Eric Bower
2026-10-26T01:48:54ZWe 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)
pkg/apps/prose/gmitext.go
-
type_declarationLineHeading2added -
type_declarationLineHeading3added -
type_declarationLineQuoteadded -
type_declarationLineTextadded -
type_declarationTextadded -
type_declarationLineadded -
type_declarationLineHeading1added -
type_declarationLineListItemadded -
method_declarationStringadded -
method_declarationlineadded -
function_declarationparseGemLinesadded -
type_declarationLineLinkadded -
type_declarationLinePreformattingToggleadded -
type_declarationLinePreformattedTextadded
+1
-1
go.mod
#
| ... | ... | @@ -162,7 +163,6 @@ require ( | |
| 162 | 163 | go.yaml.in/yaml/v3 v3.0.5 // indirect | |
| 163 | 164 | golang.org/x/image v0.46.0 // indirect | |
| 164 | 165 | golang.org/x/mod v0.41.0 // indirect | |
| 165 | - | golang.org/x/net v0.59.0 // indirect | |
| 166 | 166 | golang.org/x/sync v0.23.0 // indirect | |
| 167 | 167 | golang.org/x/sys v0.48.0 // indirect | |
| 168 | 168 | 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 | |
| 761 | 761 | cleanPath = strings.TrimSuffix(cleanPath, ".html") | |
| 762 | 762 | cleanPath = strings.TrimSuffix(cleanPath, ".md") | |
| 763 | 763 | cleanPath = strings.TrimSuffix(cleanPath, ".lxt") | |
| 764 | + | cleanPath = strings.TrimSuffix(cleanPath, ".gmi") | |
| 764 | 765 | cleanPath = strings.TrimPrefix(cleanPath, "raw/") | |
| 765 | 766 | ||
| 766 | 767 | if cleanPath != "" { |
+38
-3
pkg/apps/prose/api.go
#
| ... | ... | @@ -443,6 +443,16 @@ func (web *webRouter) postHandler(w http.ResponseWriter, r *http.Request) { | |
| 443 | 443 | unlisted = true | |
| 444 | 444 | } | |
| 445 | 445 | 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) | |
| 446 | 456 | } | |
| 447 | 457 | ||
| 448 | 458 | data = PostPageData{ |
| ... | ... | @@ -711,7 +721,20 @@ func (web *webRouter) rssBlogHandler(w http.ResponseWriter, r *http.Request) { | |
| 711 | 721 | continue | |
| 712 | 722 | } | |
| 713 | 723 | 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() | |
| 715 | 738 | } | |
| 716 | 739 | ||
| 717 | 740 | realUrl := curl.FullPostURL(post.Username, post.Slug) |
| ... | ... | @@ -809,7 +832,19 @@ func (web *webRouter) rssHandler(w http.ResponseWriter, r *http.Request) { | |
| 809 | 832 | continue | |
| 810 | 833 | } | |
| 811 | 834 | 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() | |
| 813 | 848 | } | |
| 814 | 849 | ||
| 815 | 850 | realUrl := curl.FullPostURL(post.Username, post.Slug) |
| ... | ... | @@ -950,7 +985,7 @@ func (web *webRouter) pathRoute(w http.ResponseWriter, r *http.Request) { | |
| 950 | 985 | web.postRawHandler(w, r) | |
| 951 | 986 | return | |
| 952 | 987 | } | |
| 953 | - | for _, ext := range []string{".md", ".lxt"} { | |
| 988 | + | for _, ext := range []string{".md", ".lxt", ".gmi"} { | |
| 954 | 989 | if slug, ok := strings.CutSuffix(path, ext); ok && slug != "" { | |
| 955 | 990 | r.SetPathValue("slug", slug) | |
| 956 | 991 | web.postRawHandler(w, r) |
+1
-0
pkg/apps/prose/api_test.go
#
| ... | ... | @@ -49,6 +49,7 @@ func TestRoutes(t *testing.T) { | |
| 49 | 49 | {url: site + "/hello.html", status: http.StatusOK, body: "<html"}, | |
| 50 | 50 | {url: site + "/hello.md", status: http.StatusOK, body: "# hello"}, | |
| 51 | 51 | {url: site + "/hello.lxt", status: http.StatusOK, body: "# hello"}, | |
| 52 | + | {url: site + "/hello.gmi", status: http.StatusOK, body: "# hello"}, | |
| 52 | 53 | {url: site + "/raw/hello", status: http.StatusOK, body: "# hello"}, | |
| 53 | 54 | {url: site + "/raw", status: http.StatusOK, body: "<html"}, | |
| 54 | 55 | {url: site + "/raw.md", status: http.StatusOK, body: "# raw"}, |
+1
-0
pkg/apps/prose/config.go
#
+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
#
| ... | ... | @@ -171,6 +172,10 @@ func (h *postUploader) setMeta(post *db.Post) ([]string, error) { | |
| 171 | 172 | post.PublishAt = parsedText.PublishAt | |
| 172 | 173 | } | |
| 173 | 174 | post.Hidden = parsedText.Hidden | |
| 175 | + | case ".gmi": | |
| 176 | + | post.Title = shared.ToLower(post.Slug) | |
| 177 | + | now := time.Now() | |
| 178 | + | post.PublishAt = &now | |
| 174 | 179 | case ".md": | |
| 175 | 180 | parsedText, err := shared.ParseText(post.Text) | |
| 176 | 181 | if err != nil { |
+1
-0
pkg/apps/prose/router_test.go
#
| ... | ... | @@ -38,6 +38,7 @@ func TestRouterSharedHandler(t *testing.T) { | |
| 38 | 38 | router := newFileRouter("prose", map[string]ReadWriteHandler{ | |
| 39 | 39 | ".md": posts, | |
| 40 | 40 | ".lxt": posts, | |
| 41 | + | ".gmi": posts, | |
| 41 | 42 | ".txt": &listHandler{names: []string{"a.md"}}, | |
| 42 | 43 | "fallback": &listHandler{names: []string{"x.png"}}, | |
| 43 | 44 | }) |