repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

commit
63b93d9
parent
bb7b4f2
author
Eric Bower
date
2026-01-03 23:35:04 -0500 EST
feat(prose): lists are back bby
8 files changed,  +253, -58
M pkg/apps/prose/api.go
+114, -46
  1@@ -90,6 +90,7 @@ type PostPageData struct {
  2 	Diff         template.HTML
  3 	UpdatedAtISO string
  4 	UpdatedAt    string
  5+	List         *shared.ListParsedText
  6 }
  7 
  8 type HeaderTxt struct {
  9@@ -427,22 +428,43 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
 10 	post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
 11 	if err == nil {
 12 		logger.Info("post found", "id", post.ID, "filename", post.FileSize)
 13-		parsedText, err := shared.ParseText(post.Text)
 14-		if err != nil {
 15-			logger.Error("find post with slug", "err", err.Error())
 16-		}
 17+		ext := filepath.Ext(post.Filename)
 18+		contents := template.HTML("")
 19+		tags := []string{}
 20+		unlisted := false
 21+		var list *shared.ListParsedText
 22 
 23-		if parsedText.Image != "" {
 24-			ogImage = parsedText.Image
 25-		}
 26+		switch ext {
 27+		case ".lxt":
 28+			list = shared.ListParseText(post.Text)
 29 
 30-		if parsedText.ImageCard != "" {
 31-			ogImageCard = parsedText.ImageCard
 32-		}
 33+			tags = list.Tags
 34+			if list.Image != "" {
 35+				ogImage = list.Image
 36+			}
 37+			if list.ImageCard != "" {
 38+				ogImageCard = list.ImageCard
 39+			}
 40+			if post.Hidden || post.PublishAt.After(time.Now()) {
 41+				unlisted = true
 42+			}
 43+		case ".md":
 44+			parsedText, err := shared.ParseText(post.Text)
 45+			if err != nil {
 46+				logger.Error("could not parse md text", "err", err.Error())
 47+			}
 48 
 49-		unlisted := false
 50-		if post.Hidden || post.PublishAt.After(time.Now()) {
 51-			unlisted = true
 52+			tags = parsedText.Tags
 53+			if parsedText.Image != "" {
 54+				ogImage = parsedText.Image
 55+			}
 56+			if parsedText.ImageCard != "" {
 57+				ogImageCard = parsedText.ImageCard
 58+			}
 59+			if post.Hidden || post.PublishAt.After(time.Now()) {
 60+				unlisted = true
 61+			}
 62+			contents = template.HTML(parsedText.Html)
 63 		}
 64 
 65 		data = PostPageData{
 66@@ -459,10 +481,10 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
 67 			UpdatedAtISO: post.UpdatedAt.Format(time.RFC3339),
 68 			Username:     username,
 69 			BlogName:     blogName,
 70-			Contents:     template.HTML(parsedText.Html),
 71+			Contents:     contents,
 72 			HasCSS:       hasCSS,
 73 			CssURL:       template.URL(cfg.CssURL(username)),
 74-			Tags:         parsedText.Tags,
 75+			Tags:         tags,
 76 			Image:        template.URL(ogImage),
 77 			ImageCard:    ogImageCard,
 78 			Favicon:      template.URL(favicon),
 79@@ -470,6 +492,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
 80 			Unlisted:     unlisted,
 81 			Diff:         template.HTML(diff),
 82 			WithStyles:   withStyles,
 83+			List:         list,
 84 		}
 85 	} else {
 86 		logger.Info("post not found")
 87@@ -522,6 +545,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
 88 	}
 89 
 90 	ts, err := shared.RenderTemplate(cfg, []string{
 91+		cfg.StaticPath("html/list.partial.tmpl"),
 92 		cfg.StaticPath("html/post.page.tmpl"),
 93 	})
 94 
 95@@ -651,7 +675,10 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
 96 		return
 97 	}
 98 
 99-	ts, err := template.ParseFiles(cfg.StaticPath("html/rss.page.tmpl"))
100+	ts, err := template.New("rss.page.tmpl").Funcs(shared.FuncMap).ParseFiles(
101+		cfg.StaticPath("html/list.partial.tmpl"),
102+		cfg.StaticPath("html/rss.page.tmpl"),
103+	)
104 	if err != nil {
105 		logger.Error("template parse file", "err", err.Error())
106 		http.Error(w, err.Error(), http.StatusInternalServerError)
107@@ -700,27 +727,48 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
108 		if slices.Contains(cfg.HiddenPosts, post.Filename) {
109 			continue
110 		}
111-		parsed, err := shared.ParseText(post.Text)
112-		if err != nil {
113-			logger.Error("parse post text", "err", err.Error())
114-		}
115 
116-		footer, err := dbpool.FindPostWithFilename("_footer.md", user.ID, cfg.Space)
117-		var footerHTML string
118-		if err == nil {
119-			footerParsed, err := shared.ParseText(footer.Text)
120+		content := ""
121+		ext := filepath.Ext(post.Filename)
122+		fmt.Println("HERE", post.Filename, ext)
123+		switch ext {
124+		case ".md":
125+			parsed, err := shared.ParseText(post.Text)
126 			if err != nil {
127-				logger.Error("parse footer text", "err", err.Error())
128+				logger.Error("parse post text", "err", err.Error())
129 			}
130-			footerHTML = footerParsed.Html
131-		}
132 
133-		var tpl bytes.Buffer
134-		data := &PostPageData{
135-			Contents: template.HTML(parsed.Html + footerHTML),
136-		}
137-		if err := ts.Execute(&tpl, data); err != nil {
138-			continue
139+			footer, err := dbpool.FindPostWithFilename("_footer.md", user.ID, cfg.Space)
140+			var footerHTML string
141+			if err == nil {
142+				footerParsed, err := shared.ParseText(footer.Text)
143+				if err != nil {
144+					logger.Error("parse footer text", "err", err.Error())
145+				}
146+				footerHTML = footerParsed.Html
147+			}
148+
149+			var tpl bytes.Buffer
150+			data := &PostPageData{
151+				Contents: template.HTML(parsed.Html + footerHTML),
152+			}
153+			if err := ts.Execute(&tpl, data); err != nil {
154+				logger.Error("md template", "err", err)
155+				continue
156+			}
157+			content = tpl.String()
158+		case ".lxt":
159+			parsed := shared.ListParseText(post.Text)
160+			var tpl bytes.Buffer
161+			data := &PostPageData{
162+				List: parsed,
163+			}
164+			if err := ts.Execute(&tpl, data); err != nil {
165+				logger.Error("lxt template", "err", err)
166+				continue
167+			}
168+			content = tpl.String()
169+
170 		}
171 
172 		realUrl := cfg.FullPostURL(curl, post.Username, post.Slug)
173@@ -730,7 +778,7 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
174 			Id:          feedId,
175 			Title:       utils.FilenameToTitle(post.Filename, post.Title),
176 			Link:        &feeds.Link{Href: realUrl},
177-			Content:     tpl.String(),
178+			Content:     content,
179 			Updated:     *post.PublishAt,
180 			Created:     *post.PublishAt,
181 			Description: post.Description,
182@@ -769,7 +817,10 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
183 		return
184 	}
185 
186-	ts, err := template.ParseFiles(cfg.StaticPath("html/rss.page.tmpl"))
187+	ts, err := template.New("rss.page.tmpl").Funcs(shared.FuncMap).ParseFiles(
188+		cfg.StaticPath("html/list.partial.tmpl"),
189+		cfg.StaticPath("html/rss.page.tmpl"),
190+	)
191 	if err != nil {
192 		logger.Error("template parse file", "err", err.Error())
193 		http.Error(w, err.Error(), http.StatusInternalServerError)
194@@ -788,17 +839,34 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
195 
196 	var feedItems []*feeds.Item
197 	for _, post := range pager.Data {
198-		parsed, err := shared.ParseText(post.Text)
199-		if err != nil {
200-			logger.Error(err.Error())
201-		}
202+		content := ""
203+		ext := filepath.Ext(post.Filename)
204+		switch ext {
205+		case ".md":
206+			parsed, err := shared.ParseText(post.Text)
207+			if err != nil {
208+				logger.Error(err.Error())
209+			}
210+
211+			var tpl bytes.Buffer
212+			data := &PostPageData{
213+				Contents: template.HTML(parsed.Html),
214+			}
215+			if err := ts.Execute(&tpl, data); err != nil {
216+				continue
217+			}
218+			content = tpl.String()
219+		case ".lxt":
220+			parsed := shared.ListParseText(post.Text)
221+			var tpl bytes.Buffer
222+			data := &PostPageData{
223+				List: parsed,
224+			}
225+			if err := ts.Execute(&tpl, data); err != nil {
226+				continue
227+			}
228+			content = tpl.String()
229 
230-		var tpl bytes.Buffer
231-		data := &PostPageData{
232-			Contents: template.HTML(parsed.Html),
233-		}
234-		if err := ts.Execute(&tpl, data); err != nil {
235-			continue
236 		}
237 
238 		realUrl := cfg.FullPostURL(curl, post.Username, post.Slug)
239@@ -810,7 +878,7 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
240 			Id:          realUrl,
241 			Title:       post.Title,
242 			Link:        &feeds.Link{Href: realUrl},
243-			Content:     tpl.String(),
244+			Content:     content,
245 			Created:     *post.PublishAt,
246 			Updated:     *post.UpdatedAt,
247 			Description: post.Description,
M pkg/apps/prose/config.go
+1, -0
1@@ -28,6 +28,7 @@ func NewConfigSite(service string) *shared.ConfigSite {
2 		Space:    "prose",
3 		AllowedExt: []string{
4 			".md",
5+			".lxt",
6 			".jpg",
7 			".jpeg",
8 			".png",
A pkg/apps/prose/html/list.partial.tmpl
+51, -0
 1@@ -0,0 +1,51 @@
 2+{{define "list"}}
 3+{{$indent := 0}}
 4+{{$mod := 0}}
 5+<ul style="list-style-type: {{.ListType}};">
 6+    {{range .Items}}
 7+        {{if lt $indent .Indent}}
 8+        <ul>
 9+        {{else if gt $indent .Indent}}
10+
11+        {{$mod = minus $indent .Indent}}
12+        {{range $y := intRange 1 $mod}}
13+        </li></ul>
14+        {{end}}
15+
16+        {{else}}
17+        </li>
18+        {{end}}
19+        {{$indent = .Indent}}
20+
21+        {{if .IsText}}
22+            {{if .Value}}
23+            <li>{{.Value}}
24+            {{end}}
25+        {{end}}
26+
27+        {{if .IsURL}}
28+        <li><a href="{{.URL}}">{{.Value}}</a>
29+        {{end}}
30+
31+        {{if .IsImg}}
32+        <li><img src="{{.URL}}" alt="{{.Value}}" />
33+        {{end}}
34+
35+        {{if .IsBlock}}
36+        <li><blockquote>{{.Value}}</blockquote>
37+        {{end}}
38+
39+        {{if .IsHeaderOne}}
40+        </ul><h2 class="text-xl font-bold">{{.Value}}</h2><ul style="list-style-type: {{$.ListType}};">
41+        {{end}}
42+
43+        {{if .IsHeaderTwo}}
44+        </ul><h3 class="text-lg font-bold">{{.Value}}</h3><ul style="list-style-type: {{$.ListType}};">
45+        {{end}}
46+
47+        {{if .IsPre}}
48+        <li><pre>{{.Value}}</pre>
49+        {{end}}
50+    {{end}}
51+</ul>
52+{{end}}
M pkg/apps/prose/html/post.page.tmpl
+5, -1
 1@@ -65,7 +65,11 @@
 2 </header>
 3 <main>
 4     <article class="md">
 5-        {{.Contents}}
 6+        {{if .List}}
 7+          {{template "list" .List}}
 8+        {{else}}
 9+          {{.Contents}}
10+        {{end}}
11 
12         <div class="tags">
13           {{range .Tags}}
M pkg/apps/prose/html/rss.page.tmpl
+5, -1
1@@ -1 +1,5 @@
2-{{.Contents}}
3+{{if .List}}
4+  {{template "list" .List}}
5+{{else}}
6+  {{.Contents}}
7+{{end}}
M pkg/apps/prose/scp_hooks.go
+43, -2
 1@@ -2,6 +2,7 @@ package prose
 2 
 3 import (
 4 	"fmt"
 5+	"path/filepath"
 6 	"strings"
 7 
 8 	"slices"
 9@@ -62,7 +63,28 @@ func (p *MarkdownHooks) FileValidate(s *pssh.SSHServerConnSession, data *filehan
10 	return true, nil
11 }
12 
13-func (p *MarkdownHooks) FileMeta(s *pssh.SSHServerConnSession, data *filehandlers.PostMetaData) error {
14+func (p *MarkdownHooks) metaLxt(data *filehandlers.PostMetaData) error {
15+	parsedText := shared.ListParseText(data.Text)
16+
17+	if parsedText.Title == "" {
18+		data.Title = utils.ToUpper(data.Slug)
19+	} else {
20+		data.Title = parsedText.Title
21+	}
22+
23+	data.Aliases = parsedText.Aliases
24+	data.Tags = parsedText.Tags
25+	data.Description = parsedText.Description
26+
27+	if parsedText.PublishAt != nil && !parsedText.PublishAt.IsZero() {
28+		data.PublishAt = parsedText.PublishAt
29+	}
30+	data.Hidden = parsedText.Hidden
31+
32+	return nil
33+}
34+
35+func (p *MarkdownHooks) metaMd(data *filehandlers.PostMetaData) error {
36 	parsedText, err := shared.ParseText(data.Text)
37 	if err != nil {
38 		return fmt.Errorf("%s: %w", data.Filename, err)
39@@ -81,9 +103,28 @@ func (p *MarkdownHooks) FileMeta(s *pssh.SSHServerConnSession, data *filehandler
40 	if parsedText.PublishAt != nil && !parsedText.PublishAt.IsZero() {
41 		data.PublishAt = parsedText.PublishAt
42 	}
43+	data.Hidden = parsedText.Hidden
44+
45+	return nil
46+}
47+
48+func (p *MarkdownHooks) FileMeta(s *pssh.SSHServerConnSession, data *filehandlers.PostMetaData) error {
49+	ext := filepath.Ext(data.Filename)
50+	switch ext {
51+	case ".lxt":
52+		err := p.metaLxt(data)
53+		if err != nil {
54+			return fmt.Errorf("%s: %w", data.Filename, err)
55+		}
56+	case ".md":
57+		err := p.metaMd(data)
58+		if err != nil {
59+			return fmt.Errorf("%s: %w", data.Filename, err)
60+		}
61+	}
62 
63 	isHiddenFilename := slices.Contains(p.Cfg.HiddenPosts, data.Filename)
64-	data.Hidden = parsedText.Hidden || isHiddenFilename
65+	data.Hidden = data.Hidden || isHiddenFilename
66 
67 	return nil
68 }
M pkg/apps/prose/ssh.go
+1, -0
1@@ -55,6 +55,7 @@ func StartSshServer() {
2 		".md":      filehandlers.NewScpPostHandler(dbh, cfg, hooks),
3 		".txt":     filehandlers.NewScpPostHandler(dbh, cfg, hooks),
4 		".css":     filehandlers.NewScpPostHandler(dbh, cfg, hooks),
5+		".lxt":     filehandlers.NewScpPostHandler(dbh, cfg, hooks),
6 		"fallback": uploadimgs.NewUploadImgHandler(dbh, cfg, st),
7 	}
8 	handler := filehandlers.NewFileHandlerRouter(cfg, dbh, fileMap)
M pkg/shared/listparser.go
+33, -8
 1@@ -34,12 +34,19 @@ type ListItem struct {
 2 }
 3 
 4 type ListMetaData struct {
 5-	PublishAt      *time.Time
 6-	Title          string
 7-	Description    string
 8-	Layout         string
 9-	Tags           []string
10-	ListType       string // https://developer.mozilla.org/en-US/docs/Web/CSS/list-style-type
11+	// prose
12+	Aliases     []string
13+	Description string
14+	Hidden      bool
15+	Image       string
16+	ImageCard   string
17+	Layout      string
18+	ListType    string // https://developer.mozilla.org/en-US/docs/Web/CSS/list-style-type
19+	PublishAt   *time.Time
20+	Tags        []string
21+	Title       string
22+
23+	// feeds
24 	DigestInterval string
25 	Cron           string
26 	Email          string
27@@ -101,14 +108,30 @@ func TokenToMetaField(meta *ListMetaData, token *SplitToken) error {
28 		meta.Title = token.Value
29 	case "description":
30 		meta.Description = token.Value
31+	case "image":
32+		meta.Image = token.Value
33+	case "image_card":
34+		meta.ImageCard = token.Value
35 	case "list_type":
36 		meta.ListType = token.Value
37+	case "hidden":
38+		if token.Value == "true" {
39+			meta.Hidden = true
40+		} else {
41+			meta.Hidden = false
42+		}
43 	case "tags":
44 		tags := strings.Split(token.Value, ",")
45 		meta.Tags = make([]string, 0)
46 		for _, tag := range tags {
47 			meta.Tags = append(meta.Tags, strings.TrimSpace(tag))
48 		}
49+	case "aliases":
50+		aliases := strings.Split(token.Value, ",")
51+		meta.Aliases = make([]string, 0)
52+		for _, alias := range aliases {
53+			meta.Aliases = append(meta.Aliases, strings.TrimSpace(alias))
54+		}
55 	case "layout":
56 		meta.Layout = token.Value
57 	case "digest_interval":
58@@ -206,10 +229,12 @@ func ListParseText(text string) *ListParsedText {
59 	textItems := SplitByNewline(text)
60 	items := []*ListItem{}
61 	meta := ListMetaData{
62+		Aliases:       []string{},
63+		InlineContent: true,
64+		Layout:        "default",
65 		ListType:      "disc",
66+		PublishAt:     &time.Time{},
67 		Tags:          []string{},
68-		Layout:        "default",
69-		InlineContent: true,
70 	}
71 	pre := false
72 	skip := false