repos / pico

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

pico / pkg / shared
Eric Bower  ·  2026-01-16

listparser.go

  1package shared
  2
  3import (
  4	"fmt"
  5	"html/template"
  6	"log"
  7	"regexp"
  8	"strconv"
  9	"strings"
 10	"time"
 11
 12	"github.com/araddon/dateparse"
 13)
 14
 15/*
 16=> https://www.youtube.com/watch?v=HxaD_trXwRE Lexical Scanning in Go - Rob Pike
 17func run() {
 18	for state := startState; state != nil {
 19		state = stateFn(lexer);
 20	}
 21}
 22*/
 23
 24var reIndent = regexp.MustCompile(`^[[:blank:]]+`)
 25
 26type ListParsedText struct {
 27	Items []*ListItem
 28	*ListMetaData
 29}
 30
 31type ListItem struct {
 32	Value       string
 33	URL         template.URL
 34	Variable    string
 35	IsURL       bool
 36	IsBlock     bool
 37	IsText      bool
 38	IsHeaderOne bool
 39	IsHeaderTwo bool
 40	IsImg       bool
 41	IsPre       bool
 42	IsHr        bool
 43	Indent      int
 44}
 45
 46type ListMetaData struct {
 47	// prose
 48	Aliases     []string
 49	Description string
 50	Hidden      bool
 51	Image       string
 52	ImageCard   string
 53	Layout      string
 54	PublishAt   *time.Time
 55	Tags        []string
 56	Title       string
 57
 58	// feeds
 59	DigestInterval string
 60	Cron           string
 61	Email          string
 62	InlineContent  bool // allows content inlining to be disabled in feeds.pico.sh emails
 63}
 64
 65var urlToken = "=>"
 66var blockToken = ">"
 67var varToken = "=:"
 68var imgToken = "=<"
 69var headerOneToken = "#"
 70var headerTwoToken = "##"
 71var preToken = "```"
 72var hrToken = "=="
 73
 74type SplitToken struct {
 75	Key   string
 76	Value string
 77}
 78
 79func TextToSplitToken(text string) *SplitToken {
 80	txt := strings.Trim(text, " ")
 81	token := &SplitToken{}
 82	word := ""
 83	for i, c := range txt {
 84		if c == ' ' {
 85			token.Key = strings.Trim(word, " ")
 86			token.Value = strings.Trim(txt[i:], " ")
 87			break
 88		} else {
 89			word += string(c)
 90		}
 91	}
 92
 93	if token.Key == "" {
 94		token.Key = strings.Trim(text, " ")
 95		token.Value = strings.Trim(text, " ")
 96	}
 97
 98	return token
 99}
100
101func SplitByNewline(text string) []string {
102	return strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
103}
104
105func PublishAtDate(date string) (*time.Time, error) {
106	t, err := dateparse.ParseStrict(date)
107	return &t, err
108}
109
110func TokenToMetaField(meta *ListMetaData, token *SplitToken) error {
111	switch token.Key {
112	case "date":
113		publishAt, err := PublishAtDate(token.Value)
114		if err == nil {
115			meta.PublishAt = publishAt
116		}
117	case "title":
118		meta.Title = token.Value
119	case "description":
120		meta.Description = token.Value
121	case "image":
122		meta.Image = token.Value
123	case "image_card":
124		meta.ImageCard = token.Value
125	case "draft":
126		if token.Value == "true" {
127			meta.Hidden = true
128		} else {
129			meta.Hidden = false
130		}
131	case "tags":
132		tags := strings.Split(token.Value, ",")
133		meta.Tags = make([]string, 0)
134		for _, tag := range tags {
135			meta.Tags = append(meta.Tags, strings.TrimSpace(tag))
136		}
137	case "aliases":
138		aliases := strings.Split(token.Value, ",")
139		meta.Aliases = make([]string, 0)
140		for _, alias := range aliases {
141			meta.Aliases = append(meta.Aliases, strings.TrimSpace(alias))
142		}
143	case "layout":
144		meta.Layout = token.Value
145	case "digest_interval":
146		meta.DigestInterval = token.Value
147	case "cron":
148		meta.Cron = token.Value
149	case "email":
150		meta.Email = token.Value
151	case "inline_content":
152		v, err := strconv.ParseBool(token.Value)
153		if err != nil {
154			// its empty or its improperly configured, just send the content
155			v = true
156		}
157		meta.InlineContent = v
158	}
159
160	return nil
161}
162
163func KeyAsValue(token *SplitToken) string {
164	if token.Value == "" {
165		return token.Key
166	}
167	return token.Value
168}
169
170func parseItem(meta *ListMetaData, li *ListItem, prevItem *ListItem, pre bool, mod int) (bool, bool, int) {
171	skip := false
172
173	if strings.HasPrefix(li.Value, preToken) {
174		pre = !pre
175		if pre {
176			nextValue := strings.Replace(li.Value, preToken, "", 1)
177			li.IsPre = true
178			li.Value = nextValue
179		} else {
180			skip = true
181		}
182	} else if pre {
183		nextValue := strings.Replace(li.Value, preToken, "", 1)
184		prevItem.Value = fmt.Sprintf("%s\n%s", prevItem.Value, nextValue)
185		skip = true
186	} else if strings.HasPrefix(li.Value, urlToken) {
187		li.IsURL = true
188		split := TextToSplitToken(strings.Replace(li.Value, urlToken, "", 1))
189		li.URL = template.URL(split.Key)
190		li.Value = KeyAsValue(split)
191	} else if strings.HasPrefix(li.Value, blockToken) {
192		li.IsBlock = true
193		li.Value = strings.Replace(li.Value, blockToken, "", 1)
194	} else if strings.HasPrefix(li.Value, imgToken) {
195		li.IsImg = true
196		split := TextToSplitToken(strings.Replace(li.Value, imgToken, "", 1))
197		key := split.Key
198		li.URL = template.URL(key)
199		li.Value = KeyAsValue(split)
200	} else if strings.HasPrefix(li.Value, varToken) {
201		split := TextToSplitToken(strings.Replace(li.Value, varToken, "", 1))
202		err := TokenToMetaField(meta, split)
203		if err != nil {
204			log.Println(err)
205		}
206	} else if strings.HasPrefix(li.Value, headerTwoToken) {
207		li.IsHeaderTwo = true
208		li.Value = strings.Replace(li.Value, headerTwoToken, "", 1)
209	} else if strings.HasPrefix(li.Value, headerOneToken) {
210		li.IsHeaderOne = true
211		li.Value = strings.Replace(li.Value, headerOneToken, "", 1)
212	} else if strings.HasPrefix(li.Value, hrToken) {
213		li.IsHr = true
214		li.Value = ""
215	} else if reIndent.MatchString(li.Value) {
216		trim := reIndent.ReplaceAllString(li.Value, "")
217		old := len(li.Value)
218		li.Value = trim
219
220		pre, skip, _ = parseItem(meta, li, prevItem, pre, mod)
221		if prevItem != nil && prevItem.Indent == 0 {
222			mod = old - len(trim)
223			li.Indent = 1
224		} else {
225			numerator := old - len(trim)
226			if mod == 0 {
227				li.Indent = 1
228			} else {
229				li.Indent = numerator / mod
230			}
231		}
232	} else {
233		li.IsText = true
234	}
235
236	return pre, skip, mod
237}
238
239func ListParseText(text string) *ListParsedText {
240	textItems := SplitByNewline(text)
241	items := []*ListItem{}
242	meta := ListMetaData{
243		Aliases:       []string{},
244		InlineContent: true,
245		Layout:        "default",
246		PublishAt:     &time.Time{},
247		Tags:          []string{},
248		Hidden:        false,
249	}
250	pre := false
251	skip := false
252	mod := 0
253	var prevItem *ListItem
254
255	for _, t := range textItems {
256		if len(items) > 0 {
257			prevItem = items[len(items)-1]
258		}
259
260		li := ListItem{
261			Value: t,
262		}
263
264		pre, skip, mod = parseItem(&meta, &li, prevItem, pre, mod)
265
266		if li.IsText && li.Value == "" {
267			skip = true
268		}
269
270		if !skip {
271			items = append(items, &li)
272		}
273	}
274
275	return &ListParsedText{
276		Items:        items,
277		ListMetaData: &meta,
278	}
279}