pico

created pr with 102.1 on 2026-01-04T04:57:34Z · by c8ef7d19
added 102.2 on 2026-01-04T15:06:18Z · by c8ef7d19
1: e265f5e ! 1: ff673f6 feat(prose): lists are back bby
added 102.3 on 2026-01-04T15:36:38Z · by c8ef7d19
1: ff673f6 ! 1: c86f5a4 feat(prose): lists are back bby
cmds
checkout latest patchset:
ssh pr.pico.sh print 102 | git am -3
checkout any patchset in a patch request:
ssh pr.pico.sh print 102.[rev] | git am -3
add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh pr add 102
+109 -46 pkg/apps/prose/api.go #
......@@ -90,6 +90,7 @@ type PostPageData struct {
9090 Diff template.HTML
9191 UpdatedAtISO string
9292 UpdatedAt string
93+ List *shared.ListParsedText
9394 }
9495
9596 type HeaderTxt struct {
......@@ -427,22 +428,38 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
427428 post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
428429 if err == nil {
429430 logger.Info("post found", "id", post.ID, "filename", post.FileSize)
430- parsedText, err := shared.ParseText(post.Text)
431- if err != nil {
432- logger.Error("find post with slug", "err", err.Error())
433- }
431+ ext := filepath.Ext(post.Filename)
432+ contents := template.HTML("")
433+ tags := []string{}
434+ unlisted := false
435+ var list *shared.ListParsedText
436+
437+ switch ext {
438+ case ".lxt":
439+ list = shared.ListParseText(post.Text)
440+ tags = list.Tags
441+ if post.Hidden || post.PublishAt.After(time.Now()) {
442+ unlisted = true
443+ }
444+ case ".md":
445+ parsedText, err := shared.ParseText(post.Text)
446+ if err != nil {
447+ logger.Error("could not parse md text", "err", err.Error())
448+ }
434449
435- if parsedText.Image != "" {
436- ogImage = parsedText.Image
437- }
450+ if parsedText.Image != "" {
451+ ogImage = parsedText.Image
452+ }
438453
439- if parsedText.ImageCard != "" {
440- ogImageCard = parsedText.ImageCard
441- }
454+ if parsedText.ImageCard != "" {
455+ ogImageCard = parsedText.ImageCard
456+ }
457+ tags = parsedText.Tags
442458
443- unlisted := false
444- if post.Hidden || post.PublishAt.After(time.Now()) {
445- unlisted = true
459+ if post.Hidden || post.PublishAt.After(time.Now()) {
460+ unlisted = true
461+ }
462+ contents = template.HTML(parsedText.Html)
446463 }
447464
448465 data = PostPageData{
......@@ -459,10 +476,10 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
459476 UpdatedAtISO: post.UpdatedAt.Format(time.RFC3339),
460477 Username: username,
461478 BlogName: blogName,
462- Contents: template.HTML(parsedText.Html),
479+ Contents: contents,
463480 HasCSS: hasCSS,
464481 CssURL: template.URL(cfg.CssURL(username)),
465- Tags: parsedText.Tags,
482+ Tags: tags,
466483 Image: template.URL(ogImage),
467484 ImageCard: ogImageCard,
468485 Favicon: template.URL(favicon),
......@@ -470,6 +487,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
470487 Unlisted: unlisted,
471488 Diff: template.HTML(diff),
472489 WithStyles: withStyles,
490+ List: list,
473491 }
474492 } else {
475493 logger.Info("post not found")
......@@ -522,6 +540,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
522540 }
523541
524542 ts, err := shared.RenderTemplate(cfg, []string{
543+ cfg.StaticPath("html/list.partial.tmpl"),
525544 cfg.StaticPath("html/post.page.tmpl"),
526545 })
527546
......@@ -651,7 +670,10 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
651670 return
652671 }
653672
654- ts, err := template.ParseFiles(cfg.StaticPath("html/rss.page.tmpl"))
673+ ts, err := template.New("rss.page.tmpl").Funcs(shared.FuncMap).ParseFiles(
674+ cfg.StaticPath("html/list.partial.tmpl"),
675+ cfg.StaticPath("html/rss.page.tmpl"),
676+ )
655677 if err != nil {
656678 logger.Error("template parse file", "err", err.Error())
657679 http.Error(w, err.Error(), http.StatusInternalServerError)
......@@ -700,27 +722,48 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
700722 if slices.Contains(cfg.HiddenPosts, post.Filename) {
701723 continue
702724 }
703- parsed, err := shared.ParseText(post.Text)
704- if err != nil {
705- logger.Error("parse post text", "err", err.Error())
706- }
707725
708- footer, err := dbpool.FindPostWithFilename("_footer.md", user.ID, cfg.Space)
709- var footerHTML string
710- if err == nil {
711- footerParsed, err := shared.ParseText(footer.Text)
726+ content := ""
727+ ext := filepath.Ext(post.Filename)
728+ fmt.Println("HERE", post.Filename, ext)
729+ switch ext {
730+ case ".md":
731+ parsed, err := shared.ParseText(post.Text)
712732 if err != nil {
713- logger.Error("parse footer text", "err", err.Error())
733+ logger.Error("parse post text", "err", err.Error())
714734 }
715- footerHTML = footerParsed.Html
716- }
717735
718- var tpl bytes.Buffer
719- data := &PostPageData{
720- Contents: template.HTML(parsed.Html + footerHTML),
721- }
722- if err := ts.Execute(&tpl, data); err != nil {
723- continue
736+ footer, err := dbpool.FindPostWithFilename("_footer.md", user.ID, cfg.Space)
737+ var footerHTML string
738+ if err == nil {
739+ footerParsed, err := shared.ParseText(footer.Text)
740+ if err != nil {
741+ logger.Error("parse footer text", "err", err.Error())
742+ }
743+ footerHTML = footerParsed.Html
744+ }
745+
746+ var tpl bytes.Buffer
747+ data := &PostPageData{
748+ Contents: template.HTML(parsed.Html + footerHTML),
749+ }
750+ if err := ts.Execute(&tpl, data); err != nil {
751+ logger.Error("md template", "err", err)
752+ continue
753+ }
754+ content = tpl.String()
755+ case ".lxt":
756+ parsed := shared.ListParseText(post.Text)
757+ var tpl bytes.Buffer
758+ data := &PostPageData{
759+ List: parsed,
760+ }
761+ if err := ts.Execute(&tpl, data); err != nil {
762+ logger.Error("lxt template", "err", err)
763+ continue
764+ }
765+ content = tpl.String()
766+
724767 }
725768
726769 realUrl := cfg.FullPostURL(curl, post.Username, post.Slug)
......@@ -730,7 +773,7 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
730773 Id: feedId,
731774 Title: utils.FilenameToTitle(post.Filename, post.Title),
732775 Link: &feeds.Link{Href: realUrl},
733- Content: tpl.String(),
776+ Content: content,
734777 Updated: *post.PublishAt,
735778 Created: *post.PublishAt,
736779 Description: post.Description,
......@@ -769,7 +812,10 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
769812 return
770813 }
771814
772- ts, err := template.ParseFiles(cfg.StaticPath("html/rss.page.tmpl"))
815+ ts, err := template.New("rss.page.tmpl").Funcs(shared.FuncMap).ParseFiles(
816+ cfg.StaticPath("html/list.partial.tmpl"),
817+ cfg.StaticPath("html/rss.page.tmpl"),
818+ )
773819 if err != nil {
774820 logger.Error("template parse file", "err", err.Error())
775821 http.Error(w, err.Error(), http.StatusInternalServerError)
......@@ -788,17 +834,34 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
788834
789835 var feedItems []*feeds.Item
790836 for _, post := range pager.Data {
791- parsed, err := shared.ParseText(post.Text)
792- if err != nil {
793- logger.Error(err.Error())
794- }
837+ content := ""
838+ ext := filepath.Ext(post.Filename)
839+ switch ext {
840+ case ".md":
841+ parsed, err := shared.ParseText(post.Text)
842+ if err != nil {
843+ logger.Error(err.Error())
844+ }
845+
846+ var tpl bytes.Buffer
847+ data := &PostPageData{
848+ Contents: template.HTML(parsed.Html),
849+ }
850+ if err := ts.Execute(&tpl, data); err != nil {
851+ continue
852+ }
853+ content = tpl.String()
854+ case ".lxt":
855+ parsed := shared.ListParseText(post.Text)
856+ var tpl bytes.Buffer
857+ data := &PostPageData{
858+ List: parsed,
859+ }
860+ if err := ts.Execute(&tpl, data); err != nil {
861+ continue
862+ }
863+ content = tpl.String()
795864
796- var tpl bytes.Buffer
797- data := &PostPageData{
798- Contents: template.HTML(parsed.Html),
799- }
800- if err := ts.Execute(&tpl, data); err != nil {
801- continue
802865 }
803866
804867 realUrl := cfg.FullPostURL(curl, post.Username, post.Slug)
......@@ -810,7 +873,7 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
810873 Id: realUrl,
811874 Title: post.Title,
812875 Link: &feeds.Link{Href: realUrl},
813- Content: tpl.String(),
876+ Content: content,
814877 Created: *post.PublishAt,
815878 Updated: *post.UpdatedAt,
816879 Description: post.Description,
+1 -0 pkg/apps/prose/config.go #
......@@ -28,6 +28,7 @@ func NewConfigSite(service string) *shared.ConfigSite {
2828 Space: "prose",
2929 AllowedExt: []string{
3030 ".md",
31+ ".lxt",
3132 ".jpg",
3233 ".jpeg",
3334 ".png",
+51 -0 pkg/apps/prose/html/list.partial.tmpl #
......@@ -0,0 +1,51 @@
1+{{define "list"}}
2+{{$indent := 0}}
3+{{$mod := 0}}
4+<ul style="list-style-type: {{.ListType}};">
5+ {{range .Items}}
6+ {{if lt $indent .Indent}}
7+ <ul>
8+ {{else if gt $indent .Indent}}
9+
10+ {{$mod = minus $indent .Indent}}
11+ {{range $y := intRange 1 $mod}}
12+ </li></ul>
13+ {{end}}
14+
15+ {{else}}
16+ </li>
17+ {{end}}
18+ {{$indent = .Indent}}
19+
20+ {{if .IsText}}
21+ {{if .Value}}
22+ <li>{{.Value}}
23+ {{end}}
24+ {{end}}
25+
26+ {{if .IsURL}}
27+ <li><a href="{{.URL}}">{{.Value}}</a>
28+ {{end}}
29+
30+ {{if .IsImg}}
31+ <li><img src="{{.URL}}" alt="{{.Value}}" />
32+ {{end}}
33+
34+ {{if .IsBlock}}
35+ <li><blockquote>{{.Value}}</blockquote>
36+ {{end}}
37+
38+ {{if .IsHeaderOne}}
39+ </ul><h2 class="text-xl font-bold">{{.Value}}</h2><ul style="list-style-type: {{$.ListType}};">
40+ {{end}}
41+
42+ {{if .IsHeaderTwo}}
43+ </ul><h3 class="text-lg font-bold">{{.Value}}</h3><ul style="list-style-type: {{$.ListType}};">
44+ {{end}}
45+
46+ {{if .IsPre}}
47+ <li><pre>{{.Value}}</pre>
48+ {{end}}
49+ {{end}}
50+</ul>
51+{{end}}
+5 -1 pkg/apps/prose/html/post.page.tmpl #
......@@ -65,7 +65,11 @@
6565 </header>
6666 <main>
6767 <article class="md">
68- {{.Contents}}
68+ {{if .List}}
69+ {{template "list" .List}}
70+ {{else}}
71+ {{.Contents}}
72+ {{end}}
6973
7074 <div class="tags">
7175 {{range .Tags}}
+5 -1 pkg/apps/prose/html/rss.page.tmpl #
......@@ -1,1 +1,5 @@
1-{{.Contents}}
1+{{if .List}}
2+ {{template "list" .List}}
3+{{else}}
4+ {{.Contents}}
5+{{end}}
+43 -1 pkg/apps/prose/scp_hooks.go #
......@@ -2,6 +2,7 @@ package prose
22
33 import (
44 "fmt"
5+ "path/filepath"
56 "strings"
67
78 "slices"
......@@ -62,7 +63,29 @@ func (p *MarkdownHooks) FileValidate(s *pssh.SSHServerConnSession, data *filehan
6263 return true, nil
6364 }
6465
65-func (p *MarkdownHooks) FileMeta(s *pssh.SSHServerConnSession, data *filehandlers.PostMetaData) error {
66+func (p *MarkdownHooks) metaLxt(data *filehandlers.PostMetaData) error {
67+ parsedText := shared.ListParseText(data.Text)
68+
69+ if parsedText.Title == "" {
70+ data.Title = utils.ToUpper(data.Slug)
71+ } else {
72+ data.Title = parsedText.Title
73+ }
74+
75+ data.Aliases = parsedText.Aliases
76+ data.Tags = parsedText.Tags
77+ data.Description = parsedText.Description
78+
79+ if parsedText.PublishAt != nil && !parsedText.PublishAt.IsZero() {
80+ data.PublishAt = parsedText.PublishAt
81+ }
82+
83+ isHiddenFilename := slices.Contains(p.Cfg.HiddenPosts, data.Filename)
84+ data.Hidden = parsedText.Hidden || isHiddenFilename
85+ return nil
86+}
87+
88+func (p *MarkdownHooks) metaMd(data *filehandlers.PostMetaData) error {
6689 parsedText, err := shared.ParseText(data.Text)
6790 if err != nil {
6891 return fmt.Errorf("%s: %w", data.Filename, err)
......@@ -84,6 +107,25 @@ func (p *MarkdownHooks) FileMeta(s *pssh.SSHServerConnSession, data *filehandler
84107
85108 isHiddenFilename := slices.Contains(p.Cfg.HiddenPosts, data.Filename)
86109 data.Hidden = parsedText.Hidden || isHiddenFilename
110+ return nil
111+}
112+
113+func (p *MarkdownHooks) FileMeta(s *pssh.SSHServerConnSession, data *filehandlers.PostMetaData) error {
114+ ext := filepath.Ext(data.Filename)
115+ switch ext {
116+ case ".lxt":
117+ err := p.metaLxt(data)
118+ if err != nil {
119+ return fmt.Errorf("%s: %w", data.Filename, err)
120+ }
121+ case ".md":
122+ err := p.metaMd(data)
123+ if err != nil {
124+ return fmt.Errorf("%s: %w", data.Filename, err)
125+ }
126+ default:
127+ return fmt.Errorf("%s: invalid filetype", data.Filename)
128+ }
87129
88130 return nil
89131 }
+1 -0 pkg/apps/prose/ssh.go #
......@@ -55,6 +55,7 @@ func StartSshServer() {
5555 ".md": filehandlers.NewScpPostHandler(dbh, cfg, hooks),
5656 ".txt": filehandlers.NewScpPostHandler(dbh, cfg, hooks),
5757 ".css": filehandlers.NewScpPostHandler(dbh, cfg, hooks),
58+ ".lxt": filehandlers.NewScpPostHandler(dbh, cfg, hooks),
5859 "fallback": uploadimgs.NewUploadImgHandler(dbh, cfg, st),
5960 }
6061 handler := filehandlers.NewFileHandlerRouter(cfg, dbh, fileMap)
+23 -6 pkg/shared/listparser.go #
......@@ -34,12 +34,17 @@ type ListItem struct {
3434 }
3535
3636 type ListMetaData struct {
37- PublishAt *time.Time
38- Title string
39- Description string
40- Layout string
41- Tags []string
42- ListType string // https://developer.mozilla.org/en-US/docs/Web/CSS/list-style-type
37+ // prose
38+ PublishAt *time.Time
39+ Title string
40+ Description string
41+ Layout string
42+ Tags []string
43+ Hidden bool
44+ Aliases []string
45+ ListType string // https://developer.mozilla.org/en-US/docs/Web/CSS/list-style-type
46+
47+ // feeds
4348 DigestInterval string
4449 Cron string
4550 Email string
......@@ -103,12 +108,24 @@ func TokenToMetaField(meta *ListMetaData, token *SplitToken) error {
103108 meta.Description = token.Value
104109 case "list_type":
105110 meta.ListType = token.Value
111+ case "hidden":
112+ if token.Value == "true" {
113+ meta.Hidden = true
114+ } else {
115+ meta.Hidden = false
116+ }
106117 case "tags":
107118 tags := strings.Split(token.Value, ",")
108119 meta.Tags = make([]string, 0)
109120 for _, tag := range tags {
110121 meta.Tags = append(meta.Tags, strings.TrimSpace(tag))
111122 }
123+ case "aliases":
124+ aliases := strings.Split(token.Value, ",")
125+ meta.Aliases = make([]string, 0)
126+ for _, alias := range aliases {
127+ meta.Aliases = append(meta.Aliases, strings.TrimSpace(alias))
128+ }
112129 case "layout":
113130 meta.Layout = token.Value
114131 case "digest_interval":
Back to top