repos / pico

pico services - prose.sh, pastes.sh, imgs.sh, feeds.sh, pgs.sh
git clone https://github.com/picosh/pico.git

commit
1140144
parent
f5638be
author
Eric Bower
date
2022-08-03 19:18:58 +0000 UTC
refactor: remove gemini code
21 files changed,  +1, -1238
M go.mod
M go.sum
M Dockerfile
+0, -14
 1@@ -23,7 +23,6 @@ ENV GOOS=${TARGETOS} GOARCH=${TARGETARCH}
 2 
 3 RUN go build -o /go/bin/${APP}-ssh -ldflags="-s -w" ./cmd/${APP}/ssh
 4 RUN go build -o /go/bin/${APP}-web -ldflags="-s -w" ./cmd/${APP}/web
 5-RUN [[ "${APP}" == "lists" ]] && go build -o /go/bin/${APP}-gemini -ldflags="-s -w" ./cmd/${APP}/gemini || true
 6 
 7 FROM scratch as release-ssh
 8 
 9@@ -46,16 +45,3 @@ COPY --from=builder /app/${APP}/html ./${APP}/html
10 COPY --from=builder /app/${APP}/public ./${APP}/public
11 
12 ENTRYPOINT ["/app/web"]
13-
14-FROM scratch as release-gemini
15-
16-WORKDIR /app
17-
18-ARG APP=lists
19-
20-ENV LISTS_SUBDOMAINS=0
21-
22-COPY --from=builder /go/bin/${APP}-gemini ./gemini
23-COPY --from=builder /app/lists/gmi ./${APP}/gmi
24-
25-ENTRYPOINT ["/app/gemini"]
M Makefile
+0, -2
 1@@ -23,7 +23,6 @@ bp-caddy: bp-setup
 2 bp-%: bp-setup
 3 	$(DOCKER_BUILDX_BUILD) --build-arg "APP=$*" -t "neurosnap/$*-ssh:$(DOCKER_TAG)" --target release-ssh .
 4 	$(DOCKER_BUILDX_BUILD) --build-arg "APP=$*" -t "neurosnap/$*-web:$(DOCKER_TAG)" --target release-web .
 5-	[[ "$*" == "lists" ]] && $(DOCKER_BUILDX_BUILD) --build-arg "APP=$*" -t "neurosnap/$*-gemini:$(DOCKER_TAG)" --target release-gemini . || true
 6 .PHONY: bp-%
 7 
 8 bp-all: bp-prose bp-lists bp-pastes
 9@@ -32,7 +31,6 @@ bp-all: bp-prose bp-lists bp-pastes
10 build-%:
11 	go build -o "build/$*-web" "./cmd/$*/web"
12 	go build -o "build/$*-ssh" "./cmd/$*/ssh"
13-	[[ "$*" == "lists" ]] && go build -o "build/$*-gemini" "./cmd/$*/gemini" || true
14 .PHONY: build-%
15 
16 build: build-prose build-lists build-pastes
D cmd/lists/gemini/main.go
+0, -7
1@@ -1,7 +0,0 @@
2-package main
3-
4-import "git.sr.ht/~erock/pico/lists/gemini"
5-
6-func main() {
7-	gemini.StartServer()
8-}
M go.mod
+1, -5
 1@@ -5,9 +5,8 @@ go 1.18
 2 // replace git.sr.ht/~erock/pico/wish => /home/erock/pico/wish
 3 
 4 require (
 5-	git.sr.ht/~adnano/go-gemini v0.2.3
 6-	git.sr.ht/~aw/gorilla-feeds v1.1.4
 7 	github.com/alecthomas/chroma v0.10.0
 8+	github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
 9 	github.com/charmbracelet/bubbles v0.13.0
10 	github.com/charmbracelet/bubbletea v0.22.0
11 	github.com/charmbracelet/lipgloss v0.5.0
12@@ -28,7 +27,6 @@ require (
13 
14 require (
15 	github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
16-	github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect
17 	github.com/atotto/clipboard v0.1.4 // indirect
18 	github.com/caarlos0/sshmarshal v0.1.0 // indirect
19 	github.com/charmbracelet/keygen v0.3.0 // indirect
20@@ -45,9 +43,7 @@ require (
21 	github.com/rivo/uniseg v0.2.0 // indirect
22 	go.uber.org/atomic v1.9.0 // indirect
23 	go.uber.org/multierr v1.8.0 // indirect
24-	golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e // indirect
25 	golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
26 	golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect
27-	golang.org/x/text v0.3.7 // indirect
28 	gopkg.in/yaml.v2 v2.4.0 // indirect
29 )
M go.sum
+0, -9
 1@@ -1,7 +1,3 @@
 2-git.sr.ht/~adnano/go-gemini v0.2.3 h1:oJ+Y0/mheZ4Vg0ABjtf5dlmvq1yoONStiaQvmWWkofc=
 3-git.sr.ht/~adnano/go-gemini v0.2.3/go.mod h1:hQ75Y0i5jSFL+FQ7AzWVAYr5LQsaFC7v3ZviNyj46dY=
 4-git.sr.ht/~aw/gorilla-feeds v1.1.4 h1:bL78pZ1DtHEhumHK0iWQi30uwEkWtetMfnyt9TFcdlc=
 5-git.sr.ht/~aw/gorilla-feeds v1.1.4/go.mod h1:VLpbtNDEWoaJKU41Crj6r3ChvlqYvBm56c0O6IM457g=
 6 github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
 7 github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
 8 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
 9@@ -120,12 +116,9 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
10 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
11 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
12 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
13-golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
14 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
15 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
16 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
17-golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e h1:TsQ7F31D3bUCLeqPT0u+yjp1guoArKaNKmCr22PYgTQ=
18-golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
19 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
20 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
21 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
22@@ -150,8 +143,6 @@ golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuX
23 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
24 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
25 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
26-golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
27-golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
28 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
29 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
30 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
D lists/gemini/gemini.go
+0, -581
  1@@ -1,581 +0,0 @@
  2-package gemini
  3-
  4-import (
  5-	"bytes"
  6-	"context"
  7-	"fmt"
  8-	html "html/template"
  9-	"net/url"
 10-	"os"
 11-	"os/signal"
 12-	"strconv"
 13-	"strings"
 14-	"text/template"
 15-	"time"
 16-
 17-	"git.sr.ht/~adnano/go-gemini"
 18-	"git.sr.ht/~adnano/go-gemini/certificate"
 19-	feeds "git.sr.ht/~aw/gorilla-feeds"
 20-	"git.sr.ht/~erock/pico/db"
 21-	"git.sr.ht/~erock/pico/db/postgres"
 22-	"git.sr.ht/~erock/pico/lists"
 23-	"git.sr.ht/~erock/pico/shared"
 24-	"golang.org/x/exp/slices"
 25-)
 26-
 27-func renderTemplate(cfg *shared.ConfigSite, templates []string) (*template.Template, error) {
 28-	files := make([]string, len(templates))
 29-	copy(files, templates)
 30-	files = append(
 31-		files,
 32-		cfg.StaticPath("gmi/footer.partial.tmpl"),
 33-		cfg.StaticPath("gmi/marketing-footer.partial.tmpl"),
 34-		cfg.StaticPath("gmi/base.layout.tmpl"),
 35-	)
 36-
 37-	ts, err := template.ParseFiles(files...)
 38-	if err != nil {
 39-		return nil, err
 40-	}
 41-	return ts, nil
 42-}
 43-
 44-func createPageHandler(fname string) gemini.HandlerFunc {
 45-	return func(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
 46-		logger := GetLogger(ctx)
 47-		cfg := GetCfg(ctx)
 48-		ts, err := renderTemplate(cfg, []string{cfg.StaticPath(fname)})
 49-
 50-		if err != nil {
 51-			logger.Error(err)
 52-			w.WriteHeader(gemini.StatusTemporaryFailure, "Internal Service Error")
 53-			return
 54-		}
 55-
 56-		data := shared.PageData{
 57-			Site: *cfg.GetSiteData(),
 58-		}
 59-		err = ts.Execute(w, data)
 60-		if err != nil {
 61-			logger.Error(err)
 62-			w.WriteHeader(gemini.StatusTemporaryFailure, "Internal Service Error")
 63-		}
 64-	}
 65-}
 66-
 67-func blogHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
 68-	username := GetField(ctx, 0)
 69-	dbpool := GetDB(ctx)
 70-	logger := GetLogger(ctx)
 71-	cfg := GetCfg(ctx)
 72-
 73-	user, err := dbpool.FindUserForName(username)
 74-	if err != nil {
 75-		logger.Infof("blog not found: %s", username)
 76-		w.WriteHeader(gemini.StatusNotFound, "blog not found")
 77-		return
 78-	}
 79-	posts, err := dbpool.FindUpdatedPostsForUser(user.ID, cfg.Space)
 80-	if err != nil {
 81-		logger.Error(err)
 82-		w.WriteHeader(gemini.StatusTemporaryFailure, "could not fetch posts for blog")
 83-		return
 84-	}
 85-
 86-	hostDomain := strings.Split(r.Host, ":")[0]
 87-	appDomain := strings.Split(cfg.ConfigCms.Domain, ":")[0]
 88-
 89-	onSubdomain := cfg.IsSubdomains() && strings.Contains(hostDomain, appDomain)
 90-	withUserName := (!onSubdomain && hostDomain == appDomain) || !cfg.IsCustomdomains()
 91-
 92-	ts, err := renderTemplate(cfg, []string{
 93-		cfg.StaticPath("gmi/blog.page.tmpl"),
 94-		cfg.StaticPath("gmi/list.partial.tmpl"),
 95-	})
 96-
 97-	if err != nil {
 98-		logger.Error(err)
 99-		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
100-		return
101-	}
102-
103-	headerTxt := &lists.HeaderTxt{
104-		Title: lists.GetBlogName(username),
105-		Bio:   "",
106-	}
107-	readmeTxt := &lists.ReadmeTxt{}
108-
109-	postCollection := make([]lists.PostItemData, 0, len(posts))
110-	for _, post := range posts {
111-		if post.Filename == "_header.txt" {
112-			parsedText := lists.ParseText(post.Text)
113-			if parsedText.MetaData.Title != "" {
114-				headerTxt.Title = parsedText.MetaData.Title
115-			}
116-
117-			if parsedText.MetaData.Description != "" {
118-				headerTxt.Bio = parsedText.MetaData.Description
119-			}
120-
121-			headerTxt.Nav = parsedText.Items
122-			if len(headerTxt.Nav) > 0 {
123-				headerTxt.HasItems = true
124-			}
125-		} else if post.Filename == "_readme.txt" {
126-			parsedText := lists.ParseText(post.Text)
127-			readmeTxt.Items = parsedText.Items
128-			readmeTxt.ListType = parsedText.MetaData.ListType
129-			if len(readmeTxt.Items) > 0 {
130-				readmeTxt.HasItems = true
131-			}
132-		} else {
133-			p := lists.PostItemData{
134-				URL:            html.URL(cfg.FullPostURL(post.Username, post.Slug, onSubdomain, withUserName)),
135-				BlogURL:        html.URL(cfg.FullBlogURL(post.Username, onSubdomain, withUserName)),
136-				Title:          shared.FilenameToTitle(post.Filename, post.Title),
137-				PublishAt:      post.PublishAt.Format("02 Jan, 2006"),
138-				PublishAtISO:   post.PublishAt.Format(time.RFC3339),
139-				UpdatedTimeAgo: shared.TimeAgo(post.UpdatedAt),
140-				UpdatedAtISO:   post.UpdatedAt.Format(time.RFC3339),
141-			}
142-			postCollection = append(postCollection, p)
143-		}
144-	}
145-
146-	data := lists.BlogPageData{
147-		Site:      *cfg.GetSiteData(),
148-		PageTitle: headerTxt.Title,
149-		URL:       html.URL(cfg.FullBlogURL(username, onSubdomain, withUserName)),
150-		RSSURL:    html.URL(cfg.RssBlogURL(username, onSubdomain, withUserName, "")),
151-		Readme:    readmeTxt,
152-		Header:    headerTxt,
153-		Username:  username,
154-		Posts:     postCollection,
155-	}
156-
157-	err = ts.Execute(w, data)
158-	if err != nil {
159-		logger.Error(err)
160-		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
161-	}
162-}
163-
164-func readHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
165-	dbpool := GetDB(ctx)
166-	logger := GetLogger(ctx)
167-	cfg := GetCfg(ctx)
168-
169-	page, _ := strconv.Atoi(r.URL.Query().Get("page"))
170-	pager, err := dbpool.FindAllUpdatedPosts(&db.Pager{Num: 30, Page: page}, cfg.Space)
171-	if err != nil {
172-		logger.Error(err)
173-		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
174-		return
175-	}
176-
177-	ts, err := renderTemplate(cfg, []string{
178-		cfg.StaticPath("gmi/read.page.tmpl"),
179-	})
180-
181-	if err != nil {
182-		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
183-		return
184-	}
185-
186-	nextPage := ""
187-	if page < pager.Total-1 {
188-		nextPage = fmt.Sprintf("/read?page=%d", page+1)
189-	}
190-
191-	prevPage := ""
192-	if page > 0 {
193-		prevPage = fmt.Sprintf("/read?page=%d", page-1)
194-	}
195-
196-	data := lists.ReadPageData{
197-		Site:     *cfg.GetSiteData(),
198-		NextPage: nextPage,
199-		PrevPage: prevPage,
200-	}
201-
202-	longest := 0
203-	for _, post := range pager.Data {
204-		size := len(shared.TimeAgo(post.UpdatedAt))
205-		if size > longest {
206-			longest = size
207-		}
208-	}
209-
210-	for _, post := range pager.Data {
211-		item := lists.PostItemData{
212-			URL:            html.URL(cfg.PostURL(post.Username, post.Slug)),
213-			BlogURL:        html.URL(cfg.BlogURL(post.Username)),
214-			Title:          shared.FilenameToTitle(post.Filename, post.Title),
215-			Description:    post.Description,
216-			Username:       post.Username,
217-			PublishAt:      post.PublishAt.Format("02 Jan, 2006"),
218-			PublishAtISO:   post.PublishAt.Format(time.RFC3339),
219-			UpdatedTimeAgo: shared.TimeAgo(post.UpdatedAt),
220-			UpdatedAtISO:   post.UpdatedAt.Format(time.RFC3339),
221-		}
222-
223-		item.Padding = strings.Repeat(" ", longest-len(item.UpdatedTimeAgo))
224-		data.Posts = append(data.Posts, item)
225-	}
226-
227-	err = ts.Execute(w, data)
228-	if err != nil {
229-		logger.Error(err)
230-		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
231-	}
232-}
233-
234-func postHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
235-	username := GetField(ctx, 0)
236-	slug, _ := url.PathUnescape(GetField(ctx, 1))
237-
238-	dbpool := GetDB(ctx)
239-	logger := GetLogger(ctx)
240-	cfg := GetCfg(ctx)
241-
242-	user, err := dbpool.FindUserForName(username)
243-	if err != nil {
244-		logger.Infof("blog not found: %s", username)
245-		w.WriteHeader(gemini.StatusNotFound, "blog not found")
246-		return
247-	}
248-
249-	header, _ := dbpool.FindPostWithFilename("_header.txt", user.ID, cfg.Space)
250-	blogName := lists.GetBlogName(username)
251-	if header != nil {
252-		headerParsed := lists.ParseText(header.Text)
253-		if headerParsed.MetaData.Title != "" {
254-			blogName = headerParsed.MetaData.Title
255-		}
256-	}
257-
258-	post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
259-	if err != nil {
260-		logger.Infof("post not found %s/%s", username, slug)
261-		w.WriteHeader(gemini.StatusNotFound, "post not found")
262-		return
263-	}
264-
265-	parsedText := lists.ParseText(post.Text)
266-
267-	// we need the blog name from the readme unfortunately
268-	readme, err := dbpool.FindPostWithFilename("_readme.txt", user.ID, cfg.Space)
269-	if err == nil {
270-		readmeParsed := lists.ParseText(readme.Text)
271-		if readmeParsed.MetaData.Title != "" {
272-			blogName = readmeParsed.MetaData.Title
273-		}
274-	}
275-
276-	_, err = dbpool.AddViewCount(post.ID)
277-	if err != nil {
278-		logger.Error(err)
279-	}
280-
281-	data := lists.PostPageData{
282-		Site:         *cfg.GetSiteData(),
283-		PageTitle:    lists.GetPostTitle(post),
284-		URL:          html.URL(cfg.PostURL(post.Username, post.Slug)),
285-		BlogURL:      html.URL(cfg.BlogURL(username)),
286-		Description:  post.Description,
287-		ListType:     parsedText.MetaData.ListType,
288-		Title:        shared.FilenameToTitle(post.Filename, post.Title),
289-		PublishAt:    post.PublishAt.Format("02 Jan, 2006"),
290-		PublishAtISO: post.PublishAt.Format(time.RFC3339),
291-		Username:     username,
292-		BlogName:     blogName,
293-		Items:        parsedText.Items,
294-	}
295-
296-	ts, err := renderTemplate(cfg, []string{
297-		cfg.StaticPath("gmi/post.page.tmpl"),
298-		cfg.StaticPath("gmi/list.partial.tmpl"),
299-	})
300-
301-	if err != nil {
302-		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
303-		return
304-	}
305-
306-	err = ts.Execute(w, data)
307-	if err != nil {
308-		logger.Error(err)
309-		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
310-	}
311-}
312-
313-func transparencyHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
314-	dbpool := GetDB(ctx)
315-	logger := GetLogger(ctx)
316-	cfg := GetCfg(ctx)
317-
318-	analytics, err := dbpool.FindSiteAnalytics(cfg.Space)
319-	if err != nil {
320-		logger.Error(err)
321-		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
322-		return
323-	}
324-
325-	ts, err := template.ParseFiles(
326-		cfg.StaticPath("gmi/transparency.page.tmpl"),
327-		cfg.StaticPath("gmi/footer.partial.tmpl"),
328-		cfg.StaticPath("gmi/marketing-footer.partial.tmpl"),
329-		cfg.StaticPath("gmi/base.layout.tmpl"),
330-	)
331-
332-	if err != nil {
333-		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
334-		return
335-	}
336-
337-	data := lists.TransparencyPageData{
338-		Site:      *cfg.GetSiteData(),
339-		Analytics: analytics,
340-	}
341-	err = ts.Execute(w, data)
342-	if err != nil {
343-		logger.Error(err)
344-		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
345-	}
346-}
347-
348-func rssBlogHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
349-	username := GetField(ctx, 0)
350-	dbpool := GetDB(ctx)
351-	logger := GetLogger(ctx)
352-	cfg := GetCfg(ctx)
353-
354-	user, err := dbpool.FindUserForName(username)
355-	if err != nil {
356-		logger.Infof("rss feed not found: %s", username)
357-		w.WriteHeader(gemini.StatusNotFound, "rss feed not found")
358-		return
359-	}
360-	posts, err := dbpool.FindUpdatedPostsForUser(user.ID, cfg.Space)
361-	if err != nil {
362-		logger.Error(err)
363-		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
364-		return
365-	}
366-
367-	ts, err := template.ParseFiles(
368-		cfg.StaticPath("gmi/rss.page.tmpl"),
369-		cfg.StaticPath("gmi/list.partial.tmpl"),
370-	)
371-	if err != nil {
372-		logger.Error(err)
373-		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
374-		return
375-	}
376-
377-	headerTxt := &lists.HeaderTxt{
378-		Title: lists.GetBlogName(username),
379-	}
380-
381-	for _, post := range posts {
382-		if post.Filename == "_header.txt" {
383-			parsedText := lists.ParseText(post.Text)
384-			if parsedText.MetaData.Title != "" {
385-				headerTxt.Title = parsedText.MetaData.Title
386-			}
387-
388-			if parsedText.MetaData.Description != "" {
389-				headerTxt.Bio = parsedText.MetaData.Description
390-			}
391-
392-			break
393-		}
394-	}
395-
396-	feed := &feeds.Feed{
397-		Title:       headerTxt.Title,
398-		Link:        &feeds.Link{Href: cfg.BlogURL(username)},
399-		Description: headerTxt.Bio,
400-		Author:      &feeds.Author{Name: username},
401-		Created:     time.Now(),
402-	}
403-
404-	var feedItems []*feeds.Item
405-	for _, post := range posts {
406-		if slices.Contains(cfg.HiddenPosts, post.Filename) {
407-			continue
408-		}
409-		parsed := lists.ParseText(post.Text)
410-		var tpl bytes.Buffer
411-		data := &lists.PostPageData{
412-			ListType: parsed.MetaData.ListType,
413-			Items:    parsed.Items,
414-		}
415-		if err := ts.Execute(&tpl, data); err != nil {
416-			continue
417-		}
418-
419-		item := &feeds.Item{
420-			Id:      cfg.PostURL(post.Username, post.Slug),
421-			Title:   shared.FilenameToTitle(post.Filename, post.Title),
422-			Link:    &feeds.Link{Href: cfg.PostURL(post.Username, post.Slug)},
423-			Content: tpl.String(),
424-			Created: *post.PublishAt,
425-		}
426-
427-		if post.Description != "" {
428-			item.Description = post.Description
429-		}
430-
431-		feedItems = append(feedItems, item)
432-	}
433-	feed.Items = feedItems
434-
435-	rss, err := feed.ToAtom()
436-	if err != nil {
437-		logger.Error(err)
438-		w.WriteHeader(gemini.StatusTemporaryFailure, "Could not generate atom rss feed")
439-		return
440-	}
441-
442-	// w.Header().Add("Content-Type", "application/atom+xml")
443-	_, err = w.Write([]byte(rss))
444-	if err != nil {
445-		logger.Error(err)
446-	}
447-}
448-
449-func rssHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
450-	dbpool := GetDB(ctx)
451-	logger := GetLogger(ctx)
452-	cfg := GetCfg(ctx)
453-
454-	pager, err := dbpool.FindAllPosts(&db.Pager{Num: 25, Page: 0}, cfg.Space)
455-	if err != nil {
456-		logger.Error(err)
457-		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
458-		return
459-	}
460-
461-	ts, err := template.ParseFiles(
462-		cfg.StaticPath("gmi/rss.page.tmpl"),
463-		cfg.StaticPath("gmi/list.partial.tmpl"),
464-	)
465-	if err != nil {
466-		logger.Error(err)
467-		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
468-		return
469-	}
470-
471-	feed := &feeds.Feed{
472-		Title:       fmt.Sprintf("%s discovery feed", cfg.Domain),
473-		Link:        &feeds.Link{Href: cfg.ReadURL()},
474-		Description: fmt.Sprintf("%s latest posts", cfg.Domain),
475-		Author:      &feeds.Author{Name: cfg.Domain},
476-		Created:     time.Now(),
477-	}
478-
479-	var feedItems []*feeds.Item
480-	for _, post := range pager.Data {
481-		parsed := lists.ParseText(post.Text)
482-		var tpl bytes.Buffer
483-		data := &lists.PostPageData{
484-			ListType: parsed.MetaData.ListType,
485-			Items:    parsed.Items,
486-		}
487-		if err := ts.Execute(&tpl, data); err != nil {
488-			continue
489-		}
490-
491-		item := &feeds.Item{
492-			Id:      cfg.PostURL(post.Username, post.Slug),
493-			Title:   post.Title,
494-			Link:    &feeds.Link{Href: cfg.PostURL(post.Username, post.Slug)},
495-			Content: tpl.String(),
496-			Created: *post.PublishAt,
497-		}
498-
499-		if post.Description != "" {
500-			item.Description = post.Description
501-		}
502-
503-		feedItems = append(feedItems, item)
504-	}
505-	feed.Items = feedItems
506-
507-	rss, err := feed.ToAtom()
508-	if err != nil {
509-		logger.Error(err)
510-		w.WriteHeader(gemini.StatusTemporaryFailure, "Could not generate atom rss feed")
511-	}
512-
513-	// w.Header().Add("Content-Type", "application/atom+xml")
514-	_, err = w.Write([]byte(rss))
515-	if err != nil {
516-		logger.Error(err)
517-	}
518-}
519-
520-func StartServer() {
521-	cfg := lists.NewConfigSite()
522-	db := postgres.NewDB(&cfg.ConfigCms)
523-	logger := cfg.Logger
524-
525-	certificates := &certificate.Store{}
526-	certificates.Register("localhost")
527-	certificates.Register(cfg.Domain)
528-	certificates.Register(fmt.Sprintf("*.%s", cfg.Domain))
529-	if err := certificates.Load("/var/lib/gemini/certs"); err != nil {
530-		logger.Fatal(err)
531-	}
532-
533-	routes := []Route{
534-		NewRoute("/", createPageHandler("gmi/marketing.page.tmpl")),
535-		NewRoute("/spec", createPageHandler("gmi/spec.page.tmpl")),
536-		NewRoute("/help", createPageHandler("gmi/help.page.tmpl")),
537-		NewRoute("/ops", createPageHandler("gmi/ops.page.tmpl")),
538-		NewRoute("/privacy", createPageHandler("gmi/privacy.page.tmpl")),
539-		NewRoute("/transparency", transparencyHandler),
540-		NewRoute("/read", readHandler),
541-		NewRoute("/rss", rssHandler),
542-		NewRoute("/([^/]+)", blogHandler),
543-		NewRoute("/([^/]+)/rss", rssBlogHandler),
544-		NewRoute("/([^/]+)/([^/]+)", postHandler),
545-	}
546-	handler := CreateServe(routes, cfg, db, logger)
547-	router := gemini.HandlerFunc(handler)
548-
549-	server := &gemini.Server{
550-		Addr:           "0.0.0.0:1965",
551-		Handler:        gemini.LoggingMiddleware(router),
552-		ReadTimeout:    30 * time.Second,
553-		WriteTimeout:   1 * time.Minute,
554-		GetCertificate: certificates.Get,
555-	}
556-
557-	// Listen for interrupt signal
558-	c := make(chan os.Signal, 1)
559-	signal.Notify(c, os.Interrupt)
560-
561-	errch := make(chan error)
562-	go func() {
563-		logger.Info("Starting server")
564-		ctx := context.Background()
565-		errch <- server.ListenAndServe(ctx)
566-	}()
567-
568-	select {
569-	case err := <-errch:
570-		logger.Fatal(err)
571-	case <-c:
572-		// Shutdown the server
573-		logger.Info("Shutting down...")
574-		db.Close()
575-		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
576-		defer cancel()
577-		err := server.Shutdown(ctx)
578-		if err != nil {
579-			logger.Fatal(err)
580-		}
581-	}
582-}
D lists/gemini/router.go
+0, -66
 1@@ -1,66 +0,0 @@
 2-package gemini
 3-
 4-import (
 5-	"context"
 6-	"regexp"
 7-
 8-	"git.sr.ht/~adnano/go-gemini"
 9-	"git.sr.ht/~erock/pico/db"
10-	"git.sr.ht/~erock/pico/shared"
11-	"go.uber.org/zap"
12-)
13-
14-type ctxKey struct{}
15-type ctxDBKey struct{}
16-type ctxLoggerKey struct{}
17-type ctxCfgKey struct{}
18-
19-func GetLogger(ctx context.Context) *zap.SugaredLogger {
20-	return ctx.Value(ctxLoggerKey{}).(*zap.SugaredLogger)
21-}
22-
23-func GetCfg(ctx context.Context) *shared.ConfigSite {
24-	return ctx.Value(ctxCfgKey{}).(*shared.ConfigSite)
25-}
26-
27-func GetDB(ctx context.Context) db.DB {
28-	return ctx.Value(ctxDBKey{}).(db.DB)
29-}
30-
31-func GetField(ctx context.Context, index int) string {
32-	fields := ctx.Value(ctxKey{}).([]string)
33-	return fields[index]
34-}
35-
36-type Route struct {
37-	regex   *regexp.Regexp
38-	handler gemini.HandlerFunc
39-}
40-
41-func NewRoute(pattern string, handler gemini.HandlerFunc) Route {
42-	return Route{
43-		regexp.MustCompile("^" + pattern + "$"),
44-		handler,
45-	}
46-}
47-
48-type ServeFn func(context.Context, gemini.ResponseWriter, *gemini.Request)
49-
50-func CreateServe(routes []Route, cfg *shared.ConfigSite, dbpool db.DB, logger *zap.SugaredLogger) ServeFn {
51-	return func(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
52-		curRoutes := routes
53-
54-		for _, route := range curRoutes {
55-			matches := route.regex.FindStringSubmatch(r.URL.Path)
56-			if len(matches) > 0 {
57-				ctx = context.WithValue(ctx, ctxLoggerKey{}, logger)
58-				ctx = context.WithValue(ctx, ctxDBKey{}, dbpool)
59-				ctx = context.WithValue(ctx, ctxCfgKey{}, cfg)
60-				ctx = context.WithValue(ctx, ctxKey{}, matches[1:])
61-				route.handler(ctx, w, r)
62-				return
63-			}
64-		}
65-		w.WriteHeader(gemini.StatusTemporaryFailure, "Internal Service Error")
66-	}
67-}
D lists/gmi/base.layout.tmpl
+0, -3
1@@ -1,3 +0,0 @@
2-{{define "base"}}
3-{{template "body" .}}
4-{{end}}
D lists/gmi/blog.page.tmpl
+0, -19
 1@@ -1,19 +0,0 @@
 2-{{template "base" .}}
 3-{{define "body"}}
 4-# {{.Header.Title}}
 5-{{.Header.Bio}}
 6-{{range .Header.Nav}}
 7-{{if .IsURL}}=> {{.URL}} {{.Value}}{{end}}
 8-{{- end}}
 9-=> {{.RSSURL}} rss
10-
11-{{- if .Readme.HasItems}}
12-
13----
14-{{- template "list" .Readme -}}
15-{{- end}}
16-{{- range .Posts}}
17-=> {{.URL}} {{.Title}} ({{.UpdatedTimeAgo}})
18-{{- end}}
19-{{- template "footer" . -}}
20-{{end}}
D lists/gmi/footer.partial.tmpl
+0, -5
1@@ -1,5 +0,0 @@
2-{{define "footer"}}
3----
4-
5-=> / published with {{.Site.Domain}}
6-{{end}}
D lists/gmi/help.page.tmpl
+0, -124
  1@@ -1,124 +0,0 @@
  2-{{template "base" .}}
  3-
  4-{{define "body"}}
  5-# Need help?
  6-
  7-Here are some common questions on using this platform that we would like to answer.
  8-
  9-## I get a permission denied when trying to SSH
 10-
 11-Unfortunately, due to a shortcoming in Go’s x/crypto/ssh package, Soft Serve does not currently support access via new SSH RSA keys: only the old SHA-1 ones will work. Until we sort this out you’ll either need an SHA-1 RSA key or a key with another algorithm, e.g. Ed25519. Not sure what type of keys you have? You can check with the following:
 12-
 13-```
 14-$ find ~/.ssh/id_*.pub -exec ssh-keygen -l -f {} \;
 15-```
 16-
 17-If you’re curious about the inner workings of this problem have a look at:
 18-
 19-=> https://github.com/golang/go/issues/37278 golang/go#37278
 20-=> https://go-review.googlesource.com/c/crypto/+/220037 go-review
 21-=> https://github.com/golang/crypto/pull/197 golang/crypto#197
 22-
 23-## Generating a new SSH key
 24-
 25-=> https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent Github reference
 26-
 27-```
 28-ssh-keygen -t ed25519 -C "your_email@example.com"
 29-```
 30-
 31-* When you're prompted to "Enter a file in which to save the key," press Enter. This accepts the default file location.
 32-* At the prompt, type a secure passphrase.
 33-
 34-## What should my blog folder look like?
 35-
 36-Currently {{.Site.Domain}} only supports a flat folder structure.  Therefore, `scp -r` is not permitted.  We also only allow `.txt` files to be uploaded.
 37-
 38-=> https://github.com/neurosnap/lists-blog Here is the source to my blog on this platform
 39-
 40-Below is an example of what your blog folder should look like:
 41-
 42-```
 43-blog/
 44-  first-post.txt
 45-  second-post.txt
 46-  third-post.txt
 47-```
 48-
 49-Underscores and hyphens are permitted and will be automatically removed from the title of the list.
 50-
 51-## How do I update a list?
 52-
 53-Updating a list requires that you update the source document and then run the `scp` command again.  If the filename remains the same, then the list will be updated.
 54-
 55-## How do I delete a list?
 56-
 57-Because `scp` does not natively support deleting files, I didn't want to bake that behavior into my ssh server.
 58-
 59-However, if a user wants to delete a post they can delete the contents of the file and then upload it to our server.  If the file contains 0 bytes, we will remove the post. For example, if you want to delete `delete.txt` you could:
 60-
 61-```
 62-cp /dev/null delete.txt
 63-scp ./delete.txt {{.Site.Domain}}:/
 64-```
 65-
 66-Alternatively, you can go to `ssh <username>@{{.Site.Domain}}` and select "Manage posts." Then you can highlight the post you want to delete and then press "X."  It will ask for confirmation before actually removing the list.
 67-
 68-## When I want to publish a new post, do I have to upload all posts everytime?
 69-
 70-Nope!  Just `scp` the file you want to publish.  For example, if you created a new post called `taco-tuesday.txt` then you would publish it like this:
 71-
 72-```
 73-scp ./taco-tuesday.txt {{.Site.Domain}}:/
 74-```
 75-
 76-## How do I change my blog's name?
 77-
 78-All you have to do is create a post titled `_header.txt` and add some information to the list.
 79-
 80-```
 81-=: title My new blog!
 82-=: description My blog description!
 83-=> https://xyz.com website
 84-=> https://twitter.com/xyz twitter
 85-```
 86-
 87-* `title` will change your blog name
 88-* `description` will add a blurb right under your blog name (and add meta descriptions)
 89-* The links will show up next to the `rss` link to your blog
 90-
 91-## How do I add an introduction to my blog?
 92-
 93-All you have to do is create a post titled `_readme.txt` and add some information to the list.
 94-
 95-```
 96-=: list_type none
 97-# Hi my name is Bob!
 98-I like to sing. Dance. And I like to have fun fun fun!
 99-```
100-
101-Whatever is inside the `_readme` file will get rendered (as a list) right above your blog posts. Neat!
102-
103-## What is my blog URL?
104-
105-```
106-gemini://{{.Site.Domain}}/{username}
107-```
108-
109-## How can I automatically publish my post?
110-
111-There is a github action that we built to make it easy to publish your blog automatically.
112-
113-=> https://github.com/marketplace/actions/scp-publish-action github marketplace
114-=> https://github.com/neurosnap/lists-official-blog/blob/main/.github/workflows/publish.yml example workflow
115-
116-A user also created a systemd task to automatically publish new posts.
117-
118-=> https://github.com/neurosnap/lists.sh/discussions/24 Check out this github discussion for more details.
119-
120-## Can I create multiple accounts?
121-
122-Yes!  You can either a) create a new keypair and use that for authentication or b) use the same keypair and ssh into our CMS using our special username `ssh new@{{.Site.Domain}}`.
123-Please note that if you use the same keypair for multiple accounts, you will need to always specify the user when logging into our CMS.
124-{{template "marketing-footer" .}}
125-{{end}}
D lists/gmi/list.partial.tmpl
+0, -25
 1@@ -1,25 +0,0 @@
 2-{{define "list"}}
 3-{{range .Items}}
 4-{{- if .IsText}}
 5-{{- if .Value}}
 6-* {{.Value}}
 7-{{- end}}
 8-{{- else if .IsURL}}
 9-=> {{.URL}} {{.Value}}
10-{{- else if .IsImg}}
11-=> {{.URL}} {{.Value}}
12-{{- else if .IsBlock}}
13-> {{.Value}}
14-{{- else if .IsHeaderOne}}
15-
16-## {{.Value}}
17-{{- else if .IsHeaderTwo}}
18-
19-### {{.Value}}
20-{{- else if .IsPre}}
21-```
22-{{.Value}}
23-```
24-{{- end}}
25-{{- end}}
26-{{end}}
D lists/gmi/marketing-footer.partial.tmpl
+0, -13
 1@@ -1,13 +0,0 @@
 2-{{define "marketing-footer"}}
 3----
 4-
 5-Built and maintained by pico.sh
 6-=> https://pico.sh
 7-
 8-=> / home
 9-=> /spec spec
10-=> /ops ops
11-=> /help help
12-=> /rss rss
13-=> https://git.sr.ht/~erock/pico source
14-{{end}}
D lists/gmi/marketing.page.tmpl
+0, -95
 1@@ -1,95 +0,0 @@
 2-{{template "base" .}}
 3-
 4-{{define "body"}}
 5-# {{.Site.Domain}}
 6-A microblog for lists
 7-
 8-=> /read discover some interesting lists
 9-
10----
11-
12-## Examples
13-
14-=> /news official blog
15-=> https://git.sr.ht/~erock/lists-official-blog blog source
16-
17-## Create your account
18-
19-We don't want your email address.
20-
21-To get started, simply ssh into our content management system:
22-
23-```
24-ssh new@{{.Site.Domain}}
25-```
26-
27-=> /help#permission-denied note: getting permission denied?
28-
29-After that, just set a username and you're ready to start writing!  When you SSH again, use your username that you set in the CMS.
30-
31-## You control the source files
32-
33-Create lists using your favorite editor in plain text files.
34-
35-`~/blog/days-in-week.txt`
36-
37-```
38-Sunday
39-Monday
40-Tuesday
41-Wednesday
42-Thursday
43-Friday
44-Saturday
45-```
46-
47-## Publish your posts with one command
48-
49-When your post is ready to be published, copy the file to our server with a familiar command:
50-
51-```
52-scp ~/blog/*.txt {{.Site.Domain}}
53-```
54-
55-We'll either create or update the lists for you.
56-
57-## Terminal workflow without installation
58-
59-Since we are leveraging tools you already have on your computer (`ssh` and `scp`), there is nothing to install. This provides the convenience of a web app, but from inside your terminal!
60-
61-## Plain text format
62-
63-A simple specification that is flexible and with no frills.
64-
65-=> /spec specification
66-
67-## Features
68-
69-* Just lists
70-* Looks great on any device
71-* Bring your own editor
72-* You control the source files
73-* Terminal workflow with no installation
74-* Public-key based authentication
75-* No ads, zero tracking
76-* No platform lock-in
77-* No javascript
78-* Subscriptions via RSS
79-* Not a platform for todos
80-* Minimalist design
81-* 100% open source
82-
83-## Philosophy
84-
85-I love writing lists.  I think restricting writing to a set of lists can really help improve clarity in thought.  The goal of this blogging platform is to make it simple to use the tools you love to write and publish lists.  There is no installation, signup is as easy as SSH'ing into our CMS, and publishing content is as easy as copying files to our server.
86-
87-Another goal of this microblog platform is to satisfy my own needs.  I like to write and share lists with people because I find it's one of the best way to disseminate knowledge.  Whether it's a list of links or a list of paragraphs, writing in lists is very satisfying and I welcome you to explore it on this site!
88-
89-Other blogging platforms support writing lists, but they don't emphasize them.  Writing lists is pretty popular on Twitter, but discoverability is terrible.  Other blogging platforms focus on prose, but there really is nothing out there catered specifically for lists ... until now.
90-
91-## Roadmap
92-
93-* Feature complete?
94-
95-{{template "marketing-footer" .}}
96-{{end}}
D lists/gmi/ops.page.tmpl
+0, -60
 1@@ -1,60 +0,0 @@
 2-{{template "base" .}}
 3-
 4-{{define "body"}}
 5-# Operations
 6-
 7-=> /privacy privacy
 8-=> /transparency transparency
 9-
10-## Purpose
11-
12-{{.Site.Domain}} exists to allow people to create and share their lists without the need to set up their own server or be part of a platform that shows ads or tracks its users.
13-
14-## Ethics
15-
16-We are committed to:
17-
18-* No tracking of user or visitor behaviour.
19-* Never sell any user or visitor data.
20-* No ads — ever.
21-
22-## Code of Content Publication
23-
24-Content in {{.Site.Domain}} blogs is unfiltered and unmonitored. Users are free to publish any combination of words and pixels except for: content of animosity or disparagement of an individual or a group on account of a group characteristic such as race, color, national origin, sex, disability, religion, or sexual orientation, which will be taken down immediately.
25-
26-If one notices something along those lines in a blog please let us know at {{.Site.Email}}.
27-
28-## Liability
29-
30-The user expressly understands and agrees that Eric Bower, the operator of this website shall not be liable, in law or in equity, to them or to any third party for any direct, indirect, incidental, lost profits, special, consequential, punitive or exemplary damages.
31-
32-## Account Terms
33-
34-* The user is responsible for all content posted and all actions performed with their account.
35-* We reserve the right to disable or delete a user's account for any reason at any time. We have this clause because, statistically speaking, there will be people trying to do something nefarious.
36-
37-## Service Availability
38-
39-We provide the {{.Site.Domain}} service on an "as is" and "as available" basis. We do not offer service-level agreements but do take uptime seriously.
40-
41-## Contact and Support
42-
43-Email us at {{.Site.Email}} with any questions.
44-
45-## Acknowledgments
46-
47-{{.Site.Domain}} was inspired by Mataroa Blog[0] and Bear Blog[1].
48-
49-=> https://mataroa.blog [0]mataroa blog
50-=> https://bearblog.dev [1]bearblog
51-
52-{{.Site.Domain}} is built with many open source technologies.
53-
54-In particular we would like to thank:
55-
56-=> https://charm.sh The charm community
57-=> https://go.dev The golang community
58-=> https://www.postgresql.org The postgresql community
59-=> https://github.com/caddyserver/caddy The caddy community
60-{{template "marketing-footer" .}}
61-{{end}}
D lists/gmi/post.page.tmpl
+0, -12
 1@@ -1,12 +0,0 @@
 2-{{template "base" .}}
 3-
 4-{{define "body"}}
 5-# {{.Title}}
 6-{{.PublishAt}}
 7-{{if .Description}}{{.Description}}{{end}}
 8-=> {{.BlogURL}} on {{.BlogName}}
 9-
10----
11-{{- template "list" . -}}
12-{{- template "footer" . -}}
13-{{end}}
D lists/gmi/privacy.page.tmpl
+0, -28
 1@@ -1,28 +0,0 @@
 2-{{template "base" .}}
 3-
 4-{{define "body"}}
 5-# Privacy
 6-
 7-Details on our privacy and security approach.
 8-
 9-## Account Data
10-
11-In order to have a functional account at {{.Site.Domain}}, we need to store your public key.  That is the only piece of information we record for a user.
12-
13-Because we use public-key cryptography, our security posture is a battle-tested and proven technique for authentication.
14-
15-## Third parties
16-
17-We have a strong commitment to never share any user data with any third-parties.
18-
19-## Service Providers
20-
21-We host our server on digital ocean [0]
22-
23-=> https://digitalocean.com [0]
24-
25-## Cookies
26-
27-We do not use any cookies, not even account authentication.
28-{{template "marketing-footer" .}}
29-{{end}}
D lists/gmi/read.page.tmpl
+0, -13
 1@@ -1,13 +0,0 @@
 2-{{template "base" .}}
 3-
 4-{{define "body"}}
 5-# read
 6-recently updated lists
 7-
 8-{{if .NextPage}}=> {{.NextPage}} next{{end}}
 9-{{if .PrevPage}}=> {{.PrevPage}} prev{{end}}
10-{{range .Posts}}
11-=> {{.URL}} {{.UpdatedTimeAgo}}{{.Padding}} {{.Title}} ({{.Username}})
12-{{- end}}
13-{{template "marketing-footer" .}}
14-{{end}}
D lists/gmi/rss.page.tmpl
+0, -1
1@@ -1 +0,0 @@
2-{{template "list" .}}
D lists/gmi/spec.page.tmpl
+0, -125
  1@@ -1,125 +0,0 @@
  2-{{template "base" .}}
  3-
  4-{{define "body"}}
  5-# Plain text list
  6-Speculative specification
  7-
  8-## Overview
  9-
 10-Version: 2022.05.02.dev
 11-Status: Draft
 12-Author: Eric Bower
 13-
 14-The goal of this specification is to understand how we render plain text lists. The overall design of this format is to be easy to parse and render.
 15-
 16-The format is line-oriented, and a satisfactory rendering can be achieved with a single pass of a document, processing each line independently. As per gopher, links can only be displayed one per line, encouraging neat, list-like structure.
 17-
 18-Feedback on any part of this is extremely welcome, please email {{.Site.Email}}.
 19-
 20-The source code for our parser can be found on github[0].
 21-
 22-=> https://github.com/neurosnap/lists.sh/blob/main/pkg/parser.go [0]github
 23-
 24-The source code for an example list demonstrating all the features can be found on github[1].
 25-
 26-=> https://github.com/neurosnap/lists-official-blog/blob/main/spec-example.txt [1]lists-official-blog
 27-
 28-## Parameters
 29-
 30-As a subtype of the top-level media type "text", "text/plain" inherits the "charset" parameter defined in RFC 2046[2]. The default value of "charset" is "UTF-8" for "text" content.
 31-
 32-=> https://datatracker.ietf.org/doc/html/rfc2046#section-4.1 [2]rfc 2046
 33-
 34-## Line orientation
 35-
 36-As mentioned, the text format is line-oriented. Each line of a document has a single "line type". It is possible to unambiguously determine a line's type purely by inspecting its first (3) characters. A line's type determines the manner in which it should be presented to the user. Any details of presentation or rendering associated with a particular line type are strictly limited in scope to that individual line.
 37-
 38-## File extensions
 39-
 40-{{.Site.Domain}} only supports the `.txt` file extension and will ignore all other file extensions.
 41-
 42-## List item
 43-
 44-List items are separated by newline characters `\n`. Each list item is on its own line.  A list item does not require any special formatting. A list item can contain as much text as it wants.  We encourage soft wrapping for readability in your editor of choice.  Hard wrapping is not permitted as it will create a new list item.
 45-
 46-Empty lines will be completely removed and not rendered to the end user.
 47-
 48-## Hyperlinks
 49-
 50-Hyperlinks are denoted by the prefix `=>`.  The following text should then be the hyperlink.
 51-
 52-```
 53-=> https://{{.Site.Domain}}
 54-```
 55-
 56-Optionally you can supply the hyperlink text immediately following the link.
 57-
 58-```
 59-=> https://{{.Site.Domain}} microblog for lists
 60-```
 61-
 62-## Images
 63-
 64-List items can be represented as images by prefixing the line with <code>=<</code>.
 65-
 66-```
 67-=< https://i.imgur.com/iXMNUN5.jpg
 68-```
 69-
 70-Optionally you can supply the image alt text immediately following the link.
 71-
 72-```
 73-=< https://i.imgur.com/iXMNUN5.jpg I use arch, btw
 74-```
 75-
 76-## Headers
 77-
 78-List items can be represented as headers.  We support two headers currently.  Headers will end the previous list and then create a new one after it.  This allows a single document to contain multiple lists.
 79-
 80-```
 81-# Header One
 82-## Header Two
 83-```
 84-
 85-## Blockquotes
 86-
 87-List items can be represented as blockquotes.
 88-
 89-```
 90-> This is a blockquote.
 91-```
 92-
 93-## Preformatted
 94-
 95-List items can be represented as preformatted text where newline characters are not considered part of new list items.  They can be represented by prefixing the line with ```.
 96-
 97-```
 98-#!/usr/bin/env bash
 99-
100-set -x
101-
102-echo "this is a preformatted list item!
103-```
104-
105-You must also close the preformatted text with another ``` on its own line. The next example with NOT work.
106-
107-## Variables
108-
109-Variables allow us to store metadata within our system.  Variables are list items with key value pairs denoted by `=:` followed by the key, a whitespace character, and then the value.
110-
111-```
112-=: publish_at 2022-04-20
113-```
114-
115-These variables will not be rendered to the user inside the list.
116-
117-### List of available variables:
118-
119-* `title` (custom title not dependent on filename)
120-* `description` (what is the purpose of this list?)
121-* `publish_at` (format must be `YYYY-MM-DD`)
122-* `list_type` (customize bullets; value gets sent directly to css property list-style-type[3])
123-
124-=> https://developer.mozilla.org/en-US/docs/Web/CSS/list-style-type [3]list-style-type
125-{{template "marketing-footer" .}}
126-{{end}}
D lists/gmi/transparency.page.tmpl
+0, -31
 1@@ -1,31 +0,0 @@
 2-{{template "base" .}}
 3-
 4-{{define "body"}}
 5-# Transparency
 6-
 7-## Analytics
 8-
 9-Here are some interesting stats on usage.
10-
11-Total users:
12-{{.Analytics.TotalUsers}}
13-
14-New users in the last month:
15-{{.Analytics.UsersLastMonth}}
16-
17-Total posts:
18-{{.Analytics.TotalPosts}}
19-
20-New posts in the last month:
21-{{.Analytics.PostsLastMonth}}
22-
23-Users with at least one post:
24-{{.Analytics.UsersWithPost}}
25-
26-Service maintenance costs:
27-
28-* Server $5.00/mo
29-* Domain name $3.25/mo
30-* Programmer $0.00/mo
31-{{template "marketing-footer" .}}
32-{{end}}